Skip to content

Distinguish endpoint secret decryption failures from ordinary delivery failures - #231

Merged
morcen merged 1 commit into
mainfrom
fix/issue-168-webhook-secret-decrypt-failure
Sep 21, 2026
Merged

morcen merged 1 commit into
mainfrom
fix/issue-168-webhook-secret-decrypt-failure

Conversation

@morcen

@morcen morcen commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

What was broken

Endpoint.secret_key is encrypted at rest using APP_KEY (app/Models/Endpoint.php:33). Nothing documented the correct way to rotate APP_KEY in production, and nothing in code distinguished a decryption failure from an ordinary delivery failure.

If an operator rotated APP_KEY the "obvious" way (generate a new value, replace the old one, restart) without first moving the old key into APP_PREVIOUS_KEYS, every Endpoint::secret_key became permanently undecryptable — there's no other copy of the plaintext secret anywhere. SendWebhook::handle()'s generic catch (Throwable $e) caught the resulting DecryptException the same way it catches an HTTP failure, so every delivery for every endpoint started failing and looked identical to mass endpoint downtime, with retries burning through backoff for a condition retries could never fix.

What changed

  • app/Jobs/SendWebhook.php: added a catch (\Illuminate\Contracts\Encryption\DecryptException $e) block ahead of the generic Throwable catch. It logs at critical level and records a Delivery.response_body that names the actual cause (APP_KEY rotated without preserving the previous key in APP_PREVIOUS_KEYS), instead of surfacing as an opaque/generic failure. The delivery is still scheduled for retry, since fixing APP_PREVIOUS_KEYS can make a subsequent attempt succeed.
  • DEPLOYMENT.md: added an "APP_KEY Rotation" runbook under Security Considerations describing the safe rotation procedure (move the current key into APP_PREVIOUS_KEYS, generate the new key, re-save every Endpoint row to re-encrypt under the new key, then remove the old key).
  • tests/Feature/SendWebhookSecretDecryptFailureTest.php: covers both the new catch path (secret encrypted under a key no longer in APP_KEY/APP_PREVIOUS_KEYS is caught and flagged distinctly, and still scheduled for retry) and the correct-rotation recovery path (a secret encrypted under the old key still decrypts once that key is present in APP_PREVIOUS_KEYS).

Test plan

  • vendor/bin/pint --dirty — clean
  • php artisan test — full suite passes (269 passed, 1 pre-existing skip)

Fixes #168

…livery failures

A naive APP_KEY rotation (replacing the key instead of moving the old
value into APP_PREVIOUS_KEYS) permanently breaks decryption of every
Endpoint.secret_key. Previously this DecryptException was caught by
SendWebhook's generic catch(Throwable) block and recorded as an
ordinary delivery failure, indistinguishable from a downed customer
endpoint, so the real cause (an APP_KEY misconfiguration) went
undetected while retries burned through backoff for a condition they
could never fix.

SendWebhook now catches DecryptException specifically, logs it at
critical level, and records a delivery response_body that names the
actual cause. DEPLOYMENT.md documents the correct APP_KEY rotation
procedure using APP_PREVIOUS_KEYS.
@morcen
morcen merged commit 7264647 into main Sep 21, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Encrypted Endpoint.secret_key has no documented APP_KEY-rotation runbook — a naive rotation permanently bricks every webhook signature

1 participant