All Docs
FeaturesCalmony PayUpdated March 15, 2026

Security Fix: Webhook Signing Secrets Now Encrypted at Rest

Security Fix: Webhook Signing Secrets Now Encrypted at Rest

Release: v1.0.41
Date: 2025-07-14
Severity: High
Control: SEC-06 (OWASP A02:2021 — Cryptographic Failures)


Background

Calmony Pay allows you to register webhook endpoints that receive signed event notifications. Each endpoint is issued a unique signing secret so that your server can verify incoming payloads are genuine. Until v1.0.41, those secrets were stored in plaintext in the pay_webhook_endpoints database table.

A comment in the original code explicitly flagged this as a known gap:

// In a production system this would be encrypted at rest with a KMS key.

A secretEncrypted column existed in the schema but stored the same unprotected value as the secret column — providing no additional protection. A single database breach would have exposed every webhook secret across every registered endpoint, enabling an attacker to forge authenticated webhook deliveries without being detected.


What We Fixed

Webhook secrets are now encrypted before they touch the database.

Encryption scheme

PropertyValue
AlgorithmAES-256-GCM
Key sourceWEBHOOK_ENCRYPTION_KEY environment variable
Storage targetpay_webhook_endpoints.secretEncrypted (ciphertext only)
Plaintext lifetimeIn-memory only, during secret generation and signing operations

The secretEncrypted column now fulfils its original intent — it holds ciphertext and nothing else. The legacy secret plaintext column is no longer written to.

How signing still works

Nothing changes for consumers of the webhook API. When Calmony Pay needs to sign a delivery, the secret is:

  1. Read as ciphertext from secretEncrypted.
  2. Decrypted in memory using WEBHOOK_ENCRYPTION_KEY.
  3. Used to compute the HMAC signature.
  4. Discarded — never written back to the database.

Your existing signature verification logic (X-Calmony-Signature header) requires no changes.


What You Need to Do

1. Set WEBHOOK_ENCRYPTION_KEY before deploying

The encryption key must be available as an environment variable. Generate a strong 256-bit key and store it in your secrets manager (e.g. AWS KMS, HashiCorp Vault, GCP Secret Manager) or inject it as a secret into your deployment environment:

# Example: generate a 32-byte hex key
openssl rand -hex 32

Set it as:

WEBHOOK_ENCRYPTION_KEY=<your-256-bit-hex-key>

Do not commit this value to source control. Treat it with the same sensitivity as a private key.

2. Migrate existing webhook secrets

If you have rows already present in pay_webhook_endpoints, those secrets are currently stored in plaintext. Before routing live traffic through v1.0.41, run the provided migration script to encrypt all existing secrets in place:

npx calmony-pay migrate:encrypt-webhook-secrets

The script reads each plaintext secret, encrypts it with WEBHOOK_ENCRYPTION_KEY, writes the ciphertext to secretEncrypted, and clears the plaintext secret column.

3. Verify

After deploying, confirm that:

  • New webhook endpoints return an opaque secret to the caller at creation time (this is the only moment the plaintext is visible).
  • The secretEncrypted column in your database contains ciphertext, not a recognisable string starting with whsec_.
  • Webhook deliveries continue to arrive with valid X-Calmony-Signature headers.

Threat Model Impact

ThreatBefore v1.0.41After v1.0.41
Database read accessExposes all webhook secrets in plaintextExposes only ciphertext — useless without WEBHOOK_ENCRYPTION_KEY
Database + app server compromiseFull secret exposureFull secret exposure (key is in memory) — rotate secrets immediately
Key compromise aloneN/ARotate WEBHOOK_ENCRYPTION_KEY and re-encrypt stored secrets

Encrypting at rest does not protect against a full application-layer compromise. If you suspect both your database and application environment have been breached, rotate all webhook secrets immediately via the API.


Related