# Key rotation



Webhook secrets leak. Sometimes from a misconfigured log, sometimes from an exposed env var, sometimes from a former employee's laptop. The window between &#x2A;"we should rotate this"&#x2A; and &#x2A;"the rotation is done"* is the most operationally painful part of any webhook integration if the library doesn't help.

Postel handles rotation two ways, depending on whether your producers and receivers share a secret directly (HMAC) or coordinate through public keys (Ed25519 + JWKS).

## Multi-verifier overlap (HMAC) [#multi-verifier-overlap-hmac]

Each inbound source's `verify` slot accepts a single `Verifier`, a `ReadonlyArray<Verifier>`, or a named `VerifierMap` (covered [below](#naming-verifiers-instead-of-counting-on-index)). During rotation, configure the source with both new and old — producers can sign with either, and the receiver accepts both for the duration of the overlap window.

```ts
import { Postel, Secret } from "@postel/core";
import { config } from "./config.js";

const postel = Postel({
  inbound: {
    vendor: {
      verify: [
        Secret(config.webhookSecretCurrent), // new
        Secret(config.webhookSecretPrevious), // grace-window
      ],
    },
  },
});

const { matchedVerifierIndex } = await postel.inbound.vendor.verify(body, headers);

if (matchedVerifierIndex > 0) {
  metrics.increment("postel.verify.legacy_verifier_hit", { index: matchedVerifierIndex });
}
```

`matchedVerifierIndex` tells you which one matched. Watch the metric: when legacy-verifier hits drop to zero across all producers, remove the entry and finalize the rotation.

### Naming verifiers instead of counting on index [#naming-verifiers-instead-of-counting-on-index]

A positional index loses its meaning the moment the array is reordered — add a third secret ahead of the other two, and `matchedVerifierIndex` for the old entries silently shifts. Pass a named map instead, and the result reports `matchedVerifier` by name:

```ts
const postel = Postel({
  inbound: {
    vendor: {
      verify: {
        current: Secret(config.webhookSecretCurrent),
        legacy: Secret(config.webhookSecretPrevious),
      },
    },
  },
});

const { matchedVerifier } = await postel.inbound.vendor.verify(body, headers);

if (matchedVerifier === "legacy") {
  metrics.increment("postel.verify.legacy_verifier_hit");
}
```

`matchedVerifierIndex` is still reported alongside `matchedVerifier` (the map's insertion order); only the array and single-`Verifier` forms omit `matchedVerifier` entirely, since there's no name to give. This is purely additive — the array form keeps working exactly as above.

### Safe rotation playbook [#safe-rotation-playbook]

1. **Issue the new secret.** Add it to your secrets store. Configure the source with `verify: [Secret(NEW), Secret(OLD)]`. Deploy.
2. **Roll the producer.** Update producers one at a time to sign with the new secret. The receiver continues to accept the old one for the duration of the rollout.
3. **Watch for legacy hits.** Once `matchedVerifierIndex > 0` stops firing for some agreed-upon window (typically 7–30 days), the old secret is no longer in use.
4. **Remove the old secret.** Reconfigure with `verify: Secret(NEW)`. Revoke `OLD` from the secrets store. Done.

No flag day, no coordinated deploys, no requests dropped during the cutover.

### Why an array, not a "primary + fallback" [#why-an-array-not-a-primary--fallback]

The `Verifier | ReadonlyArray<Verifier>` shape is deliberate. Postel makes no distinction between "primary" and "fallback" — both are valid at any moment. A producer that signed with the third entry in the array is just as accepted as one that signed with the first. The order only controls which `matchedVerifierIndex` you observe.

This generalizes beyond same-scheme rotation in two ways:

* **Cross-scheme migration.** Mix HMAC and Ed25519: `verify: [Secret(LEGACY_HMAC), Keyset({ jwksUri: NEW_JWKS })]`. During the window the receiver accepts either; once `matchedVerifierIndex === 0` stops firing the HMAC path can be removed.
* **Multi-tenant receivers.** `Postel({ inbound })` sources are fixed at factory time, so per-tenant secrets assembled at request time go through the low-level `verify(rawBody, headers, secretOrKeyset)` export instead — same checks, you supply the keys per call.

## JWKS — asymmetric rotation without secret sharing [#jwks--asymmetric-rotation-without-secret-sharing]

For Ed25519 signing (`v1a`), receivers verify with a public key — they never hold signing material. The producer publishes its public keys at a JWKS endpoint (commonly `/.well-known/webhooks-keys`); the receiver fetches and caches them.

```ts
import { Postel, Keyset } from "@postel/core";

const postel = Postel({
  inbound: {
    vendor: {
      verify: Keyset({
        jwksUri: "https://producer.example.com/.well-known/webhooks-keys",
        refreshEvery: 60 * 60,   // refetch every hour
        cacheTtl: 24 * 60 * 60,  // keep keys cached for 24h
      }),
    },
  },
});

export async function POST(req: Request) {
  const body = new Uint8Array(await req.arrayBuffer());
  const { event } = await postel.inbound.vendor.verify(body, Object.fromEntries(req.headers));
  // ...
}
```

The `Keyset(...)` strategy returns a cached, auto-refreshing keyset. The receiver looks up the public key by `kid` (extracted from the `webhook-key-id&#x60; header), so rotation is just &#x2A;"add a new key to the JWKS, mark the old one's `not_after`, and the receiver picks it up on the next refresh."*

### Publishing a JWKS (producer side) [#publishing-a-jwks-producer-side]

If you're on the producing side, mounting a JWKS endpoint is one line:

```ts title="app/.well-known/webhooks-keys/route.ts"
import { jwksHandler } from "@postel/core";
import { postel } from "@/lib/postel";

// publicJwks() returns the sender's current public keys (primary + in-overlap),
// each with the same kid the signer stamps as webhook-key-id — so receivers
// resolve the right key by kid. jwksHandler serves them public-only.
export async function GET(req: Request) {
  const { keys } = await postel.outbound.keys.publicJwks();
  return jwksHandler({ keys })(req);
}
```

`outbound.keys.publicJwks()` returns the active Ed25519 public keys in the standard JWKS shape — only public key material, never private. The handler also refuses to serve a key that includes `d` (the Ed25519 private scalar). Wire it into whichever HTTP layer you use; the handler returns a `Response`.

The kid is the RFC 7638 JWK thumbprint of the public key, and the sender stamps the matching `webhook-key-id` on every Ed25519 request — so receivers resolve the right key by kid. Framework adapters wrap this as a one-line `outbound.bindJwks()` mount — defaulting to `GET /.well-known/webhooks-keys` with the `() => outbound.keys.publicJwks()` provider, refetched per request:

<Tabs items="[&#x22;Hono&#x22;, &#x22;Express&#x22;, &#x22;Fastify&#x22;]">
  <Tab value="Hono">
    ```ts
    HonoWebAdapter(postel, app).outbound.bindJwks();
    ```
  </Tab>

  <Tab value="Express">
    ```ts
    ExpressWebAdapter(postel, app).outbound.bindJwks();
    ```
  </Tab>

  <Tab value="Fastify">
    ```ts
    FastifyWebAdapter(postel, app).outbound.bindJwks();
    ```
  </Tab>
</Tabs>

### When to choose JWKS over multi-secret [#when-to-choose-jwks-over-multi-secret]

* **You operate the producer and the receiver.** Use HMAC + multi-secret. Simpler, faster, no extra endpoint to monitor.
* **You operate the producer; third parties receive.** Use JWKS. Receivers verify without ever holding signing material — leaks at the receiver don't compromise future signatures.
* **You operate the receiver; third parties produce.** The third party chooses. Postel verifies both.

## Ephemeral keys [#ephemeral-keys]

JWKS unlocks **ephemeral signing keys*&#x2A;: the producer rotates a new keypair every N hours, publishes the public half, drops the old private key. Receivers pick up rotations automatically as long as they refetch the JWKS. The compromise window for any leaked private key shrinks from &#x2A;"until manual rotation"&#x2A; to &#x2A;"until the next auto-rotation."*

Postel ships the consumer side of this today (the `Keyset({...})` strategy). The producer-side rotation is available on the sender via `rotateSecret` and asymmetric key generation — see [Outbound](/docs/outbound).

## What's next [#whats-next]

* [Deduplication](/docs/inbound/deduplication) — handling at-least-once.
* [Raw bytes](/docs/concepts/raw-bytes) — the silent killer of signature verification.
