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
- 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.
- Configure the new secret at the provider. Both are now valid inbound.
- Watch
secretIduntil no delivery reports the old label. - 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.