5 August 2026

Testing a signed webhook before the sender exists

The counterparty holds the private key, so their payloads cannot be produced locally. Standing in for them turns a blocked integration into an ordinary one.

An integration that receives signed payloads has an awkward property: the half under test cannot produce its own input. The signature is made with the sender's private key, and that key is theirs. Until they are ready, there is nothing realistic to send.

The usual responses are all worse than they look.

Waiting. The handler is written, reviewed and merged without ever having run against a real artifact. Integration begins on the day the counterparty is ready, which is also the day every remaining question surfaces at once.

A stub that skips verification. The handler is exercised with a flag that turns the signature check off. What is tested is the parsing and the business logic; what is not tested is the part that decides whether to trust the message at all.

Signing with a key generated locally. Closer, and the shape of the artifact is right. It works until the moment the real key set is introduced, at which point the parts that were quietly hardcoded - a key id, an issuer, an algorithm - all surface together.

The third is the right instinct. What it lacks is a second party: something that holds a key set, publishes the public half where a verifier can fetch it, and produces artifacts on request.

Standing in for the sender

The pieces are ordinary. A key set, kept somewhere stable. A published JWKS at a URL the verifier can be pointed at. And a way to ask for a payload, signed with that key, containing claims chosen for the case being tested.

With those, the handler is exercised end to end before the counterparty exists: it fetches a key set over HTTP, resolves a kid, checks a signature it did not make, and reaches a verdict. The only thing that changes on the day the counterparty is ready is which URL the key set comes from.

That last point is what makes the exercise worth doing. An integration that has only ever verified against a key generated in the same process has not tested key resolution, and key resolution is where rotation failures live.

The cases worth having

A valid payload proves the handler works when nothing is wrong, which is the least interesting thing about it. A receiver's job is to refuse, and refusal is what goes untested.

  • An expired payload. Confirms the timestamp is checked at all. A handler that accepts an expired message accepts a replayed one.
  • A payload signed with a key that is not published. Confirms the handler cares which key signed, rather than that something did.
  • A payload whose body was changed after signing. The signature is genuine and no longer matches. This is precisely what signing exists to catch, and a handler that parses before verifying will often not catch it.
  • A payload with the signature removed. Some handlers treat an absent signature as nothing to check.
  • A payload declaring no algorithm. If it is accepted, anyone can send anything.

Each of these has an outcome the handler should reach, and asserting against that outcome is what makes the test meaningful. A suite that checks "an exception was thrown" passes for the wrong reasons about as often as the right ones - a malformed test fixture throws too.

Where clock skew comes in

Two of the cases above are about time, and time is the one input a test environment usually gets wrong by removing it. A payload with a not-yet-valid timestamp is rarely an attack; it is two machines a few seconds apart. A handler with no tolerance will reject perfectly good messages under load, and one with several minutes of tolerance will accept messages that expired some time ago. Both are worth deciding deliberately rather than inheriting from a library default.

What this buys

Nothing about release day is a first. The handler has verified real signatures, resolved keys over the network, rejected the cases it should reject, and done so in CI on every commit since. The counterparty's arrival becomes a configuration change rather than the start of integration.

The bench produces these artifacts for JOSE and OpenPGP, including the malformed variants no library will generate. The test tokens list what each case is and the outcome a service ought to reach, with the weakness class each one covers. Test keys only - nothing here is a statement about any particular service, and "it passed" is not a security guarantee.

Reference

URL or embedded key?

Your app needs the public key to check a token. It can fetch it from a URL at runtime, or carry it as a file. Both are normal. The trade is always the same one: who controls rotation.

Fetch from the URLEmbed the key
RotationAutomatic on next fetchNeeds a redeploy
NetworkDepends on the URLNone
TrustWhatever the endpoint servesPinned to one key
SuitsA signer that rotates keysOffline, air-gapped, or pinned

Use the URL when the signer rotates its keys, which is what every identity provider does. That is why it became the norm, and it is usually a one-line config change.

Embed the key when you cannot make a network call while checking a token, or when you want your app pinned to one key so a compromised endpoint could not introduce another. Export the PEM from the Keysets tab.

One catch worth knowing: Spring Boot's public-key-location only loads RSA keys. An ES256 or EdDSA key needs a custom decoder, or the URL.