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.42 Severity: High OWASP Category: A02 — Cryptographic Failures Control: SEC-06

What was the issue?

Every webhook endpoint registered with Calmony Pay is assigned a unique signing secret via generateWebhookSecret(). This secret is used to sign outbound webhook payloads so that your server can verify the delivery is genuine and has not been tampered with.

Prior to v1.0.42, that secret was stored in plaintext in the pay_webhook_endpoints.secret database column. A secretEncrypted column existed alongside it, but contained the same plaintext value — the encryption step was noted in a code comment as a future production requirement but had not been implemented.

Impact: If the database were compromised, an attacker would immediately obtain every webhook signing secret across all registered endpoints. With those secrets, they could forge valid webhook signatures and deliver arbitrary payloads to any endpoint, bypassing integrity checks entirely.

What changed?

Starting in v1.0.42, the raw signing secret is encrypted using AES-256-GCM before it is written to the database:

// src/app/api/v1/webhook_endpoints/route.ts
const rawSecret = generateWebhookSecret();
const encrypted = encrypt(rawSecret, process.env.WEBHOOK_ENCRYPTION_KEY);
// store `encrypted` in secretEncrypted — rawSecret is never persisted

The secretEncrypted column now stores only ciphertext. When a webhook delivery is made, the secret is decrypted in memory for signing purposes and is not written anywhere in its raw form.

How does AES-256-GCM protect you?

  • Confidentiality: The ciphertext reveals nothing about the plaintext secret without the encryption key.
  • Integrity: GCM's authentication tag ensures the ciphertext cannot be silently tampered with.
  • Key separation: The encryption key (WEBHOOK_ENCRYPTION_KEY) is held outside the database — in an environment variable or KMS — so a database breach alone is not sufficient to recover the secrets.

What do you need to do?

1. Set the encryption key

Before deploying v1.0.42, ensure the following environment variable is configured in every environment (production, staging, development):

WEBHOOK_ENCRYPTION_KEY=<32-byte hex or base64 key>

This key must be stored securely — in a secrets manager, KMS-backed parameter store, or equivalent. Do not commit it to source control.

2. Migrate existing secrets

Any signing secrets already stored in plaintext must be re-encrypted before the new code runs against them. Run the provided migration utility after deploying the new release:

# Example — consult your deployment runbook for the exact command
npx calmony-pay migrate:encrypt-webhook-secrets

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

3. Verify webhook signature validation is unaffected

Your receiving endpoints validate signatures using the secret Calmony Pay returns to you at endpoint creation time. That value has not changed — only its storage representation has. No changes are required on your side.

Summary of changes

AreaBefore v1.0.42v1.0.42 and later
secret columnPlaintext signing secretCleared / unused
secretEncrypted columnPlaintext signing secret (no encryption)AES-256-GCM ciphertext
Signing at deliveryRead plaintext directlyDecrypt in memory, then sign
Key requiredNoneWEBHOOK_ENCRYPTION_KEY

References