Skip to content

Secret rotation

Secret rotation

Why this is hard

You cannot rotate a webhook secret atomically. The sender and the receiver are different systems that deploy at different times, so there is always a window in which some deliveries are signed with the old secret and some with the new one.

A receiver holding exactly one secret drops one side of that window. Which side depends on the order you change things, and neither order is right:

  • Change the receiver first, and every delivery signed with the old secret is refused until the sender catches up.
  • Change the sender first, and every delivery signed with the new secret is refused until the receiver catches up.

So an endpoint holds a set.

'endpoints' => [
    'github' => [
        'scheme'  => 'github',
        'secrets' => [
            'rotated-2026-08' => env('GITHUB_WEBHOOK_SECRET'),
            'legacy'          => env('GITHUB_WEBHOOK_SECRET_OLD'),
        ],
    ],
],

Every configured secret is tried. A delivery signed with either one verifies.

Knowing when it is safe to finish

The labels are not decoration. VerifiedWebhook::$secretId reports which secret verified, which is the signal that tells you whether the sender has actually moved:

$verified = VerifiedWebhook::fromRequest($request);

Log::withContext(['webhook_secret' => $verified->secretId]);

Chart it. While anything still reports legacy, removing the old secret would start refusing genuine deliveries. When it has been zero for longer than the provider's retry window, the rotation is done.

Without that signal, retiring the old secret is a guess — which is why most rotations either never finish or finish with an incident.

The procedure

  1. Add the new secret to the endpoint, first in the list. Deploy. Nothing changes for inbound traffic yet, and outbound signing moves to the new secret immediately.
  2. Configure the new secret at the provider. Both are now valid inbound.
  3. Watch secretId until no delivery reports the old label.
  4. Remove the old secret. Deploy.

Step 3 is the one people skip.

Which secret signs outbound

The first in the list. During a rotation the new secret leads, so your outbound traffic moves to it immediately while inbound still accepts both — the order that lets the far side catch up without dropping anything.

Timing behaviour

Every configured secret is evaluated on every verification, without returning early on the first match. That keeps the work done independent of which secret verified, so the response time does not reveal whether a sender has rotated yet.

Every comparison uses hash_equals, which does not short-circuit on the first differing byte. A === comparison leaks the length of the matching prefix through timing, and a patient attacker can walk a forged MAC out of that one byte at a time.

Asymmetric schemes rotate too

For postal the configured values are PEM public keys rather than shared secrets. They are not confidential, but the rotation problem is identical — the installation's key changes and the receiver has to accept both until it has — so the same set, the same labels, and the same secretId signal apply.

Configuration shapes

'secrets' => 'one-secret',                          // a single string
'secrets' => ['secret-a', 'secret-b'],              // a list, unlabelled
'secrets' => ['new' => 'secret-b', 'old' => 'secret-a'],   // labelled — do this

Labels cost nothing and are what make step 3 possible.

Empty and null entries are dropped. An endpoint that ends up with no secrets refuses everything with FailureReason::NoSecretConfigured — the failure mode being guarded is an unset environment variable, and treating an empty secret as "verification disabled" would turn a deploy mistake into an open endpoint.