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
| Property | Value |
|---|---|
| Algorithm | AES-256-GCM |
| Key source | WEBHOOK_ENCRYPTION_KEY environment variable |
| Storage target | pay_webhook_endpoints.secretEncrypted (ciphertext only) |
| Plaintext lifetime | In-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:
- Read as ciphertext from
secretEncrypted. - Decrypted in memory using
WEBHOOK_ENCRYPTION_KEY. - Used to compute the HMAC signature.
- 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
secretEncryptedcolumn in your database contains ciphertext, not a recognisable string starting withwhsec_. - Webhook deliveries continue to arrive with valid
X-Calmony-Signatureheaders.
Threat Model Impact
| Threat | Before v1.0.41 | After v1.0.41 |
|---|---|---|
| Database read access | Exposes all webhook secrets in plaintext | Exposes only ciphertext — useless without WEBHOOK_ENCRYPTION_KEY |
| Database + app server compromise | Full secret exposure | Full secret exposure (key is in memory) — rotate secrets immediately |
| Key compromise alone | N/A | Rotate 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.