Verifying a delivery
Every delivery is signed with our Ed25519 key and verified against the JWK Set we publish — JWS detached, RFC 7515.
Encryption answers who may read a delivery. It cannot answer who sent it: sealing a body to a public key is something a public key lets anyone do, so a handler that decrypted successfully has only learned that the sender knew a value that was never secret.
So every delivery also carries a signature, made with a key only we hold, over the exact bytes on the wire.
The two checks
| Answers | Made with | |
|---|---|---|
webhook-signature | did Stridee send this | our private key, verified against our published public one |
| the JWE body | can only you read this | your private key, sealed with your public one |
Neither implies the other, and a handler needs both. The direction is what makes them different: your encryption key is yours — you hold the private half, we hold the public. Our signing key is ours, and you never hold any part of it.
Where the keys come from
https://api.stridee.fit/.well-known/jwks.jsonA JWK Set of Ed25519 public keys, the same shape
every OIDC provider serves — so a client library already handles fetching, caching, kid
selection and rollover:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "_1dXXcevi_xNCDfMHOIBe2hqiBRdxVealY40Yv6akI4",
"use": "sig",
"alg": "EdDSA",
"kid": "whk_2026_08"
}
]
}Fetch it at startup and cache it — Cache-Control says ten minutes. Don't pin a key by
copying x into your config: that turns our rotation into your redeploy, which is the
thing publishing a set exists to avoid.
More than one key means a rotation is in progress. Both are valid; the kid in the
signature says which one signed.
The header
webhook-signature is a JWS with detached content
(RFC 7515 Appendix F):
webhook-signature: eyJhbGciOiJFZERTQSIsImtpZCI6…..iHpBfMmUqShn7ikCKRQc_GbqAGfi…Three segments, and the middle one is empty. That is the payload slot, and the payload is the request body you already have — carrying it twice would let the two copies disagree, and then the interesting question becomes which one was signed.
{
"alg": "EdDSA",
"kid": "whk_2026_08",
"webhook-id": "c41e9b02-7a3d-4e58-8f19-6b0d2c85af73",
"webhook-timestamp": 1770124811
}The delivery's id and timestamp are inside the signed header, not only in the HTTP headers beside it. That is what makes them evidence rather than decoration.
Read the id and timestamp from the verified header, never from req.headers. Both
carry the same values. Only one of them is signed. The HTTP headers exist so a proxy can
route and a log can be grepped.
Verifying
import { base64url, createRemoteJWKSet, flattenedVerify } from 'jose';
// Once, at startup. One per request is a fetch per request.
const JWKS = createRemoteJWKSet(new URL('https://api.stridee.fit/.well-known/jwks.json'));
const [protectedSegment, , signature] = req.headers['webhook-signature'].split('.');
const { protectedHeader } = await flattenedVerify(
{ protected: protectedSegment, signature, payload: base64url.encode(rawBody) },
JWKS,
{ algorithms: ['EdDSA'] }
);The payload is base64url-encoded before verifying because the JWS uses the default
b64: true, so the signing input is the encoded body rather than the raw bytes.
Verify before you parse. JSON.parse on unverified bytes is work done on behalf of
whoever sent them.
Freshness and replay
A signature does not expire. Without a window, a captured delivery replays forever:
const age = Math.abs(Math.floor(Date.now() / 1000) - protectedHeader['webhook-timestamp']);
if (age > 300) throw new Error('outside the tolerance window');Five minutes is a clock-skew allowance, not a delivery-time allowance. It bounds how long a replay stays useful; it does not stop one.
What stops one is the delivery id:
if (await seen(protectedHeader['webhook-id'])) return res.json({ nonce: event.nonce });Retries reuse the id deliberately, so this is also how you avoid processing the same event twice. The window bounds a replay, the id is what stops it — you want both.
The order to do it in
// 1. who sent it — before anything is parsed
const { protectedHeader } = await flattenedVerify(…, JWKS, { algorithms: ['EdDSA'] });
// 2. is it recent
assertFresh(protectedHeader['webhook-timestamp']);
// 3. have we seen it
if (await seen(protectedHeader['webhook-id'])) return res.json({ nonce: … });
// 4. what it says
const { enc } = JSON.parse(rawBody);
const { plaintext } = await compactDecrypt(enc, keyFor(kidOf(enc)));
const event = JSON.parse(new TextDecoder().decode(plaintext));
// 5. whose it is — account_id is inside the ciphertext
assertOwned(event.account_id);
// 6. prove you opened it
res.json({ nonce: event.nonce });Each step is cheaper than the one after it, which is the other reason for the order: an unsigned POST costs one signature check, not a decrypt and a database round trip.
What we rotate, and what you do about it
Nothing. Publishing a set is what makes rotation ours to run:
- We publish a second key. Both are in the set; the first still signs.
- Caches expire — ten minutes, or immediately for a client that meets an unknown
kid. - We start signing with the second. Every verifier already has it.
A client using createRemoteJWKSet or an equivalent needs no change at any step. One that
pinned a copied x value breaks at step 3.
If you cannot verify
Verification failing on a delivery you expected means one of:
signature verification failed | the body was modified in transit, or you verified re-serialized JSON rather than the raw bytes |
no key matched the kid | your cached set is stale and your client doesn't re-fetch — check it isn't a hardcoded key |
| the header is missing | something upstream stripped it; some proxies drop unknown headers |
The second row is the common one, and the fix is always the same: fetch the set, don't paste the key.
Verify the bytes you received. A framework that parses JSON for you and hands back an object has already thrown away the thing that was signed — re-serializing it produces different bytes, with the keys in a different order or the spacing changed, and that will not verify. Take the raw body.
Something wrong or missing on this page? Tell us in Discord.