PasskeyBridge

Security · 2026-08-27

Running CAEP in Production: Signing Outbound SETs and the Receivers That Take Them

By J. W. Bouckaert

Running CAEP in Production: Signing Outbound SETs and the Receivers That Take Them

The other half of the wire

Our earlier piece on SSF and CAEP was about receiving: a signal arrives from another identity provider, and PasskeyBridge fires a revocation cascade in seconds instead of waiting for a token to expire. This post is the other direction. PasskeyBridge is also a transmitter: when something changes on a subject we own, we build a Security Event Token, sign it, and push it to receivers operated by other parties.

Signing a token is the easy part. A SET is a JWT, you have a key, you produce a signature, done. The hard part is that the receiver is not yours. It runs software you did not write, enforces rules you did not agree to, and returns status codes you have to interpret correctly at three in the morning. Everything below is a lesson the receivers taught us, and every one of them changed the transmitter.

A signed SET on the wire

Concretely, our transmitter emits a standard compact JWS with three segments. The header declares the algorithm, the token type, and the key id:

{ "alg": "ES256", "typ": "secevent+jwt", "kid": "<tenant-key-id>" }

The typ of secevent+jwt is the RFC 8417 marker that tells a receiver "this is a Security Event Token; do not treat it as an access token or a credential." The payload is deliberately small:

{
  "iss": "https://api.passkeybridge.io/v1/tenants/<slug>",
  "aud": "<the receiver's configured audience>",
  "iat": 1756...,
  "jti": "<unique per event>",
  "events": {
    "https://schemas.openid.net/secevent/caep/event-type/...": {
      "subject": { "format": "opaque", "id": "sha256:<hex>" }
    }
  }
}

There is no exp and no sub. A SET describes an event that already happened rather than a session that is still valid, so an expiry would be meaningless; the subject rides inside the event object, below the top-level claims. We push it over HTTP with Content-Type: application/secevent+jwt per RFC 8935, the push-based delivery profile. The whole artifact is a few hundred bytes, signed once, and that single signature is the only thing authenticating us to the receiver. There is no mutual TLS in the SSF push model. The receiver trusts the token because it can verify the signature, and for no other reason. That fact is the root of most of what follows.

Lesson 1: A 2xx means the receiver acted

The first thing production teaches is that a receiver's HTTP response is not really about HTTP. When we POST a SET, the status code tells us what the receiver thinks of the token, and our delivery worker has to translate that into an action. The classifier we settled on:

Receiver responseWhat it meansWhat the transmitter does
2xxAccepted and processedMark delivered. Done.
4xx (except 408, 429)The SET itself was rejectedDead-letter it. Stop.
408, 429, 5xx, timeout, network errorTransient, the token may be fineRetry with backoff

The load-bearing row is the middle one. A 400 or 422 from a receiver has nothing to do with the network. The receiver is saying "I parsed your token and I refuse it": bad audience, an event type I do not support, a signature I cannot verify, a subject format I do not accept. Retrying a semantically rejected SET just delivers the same rejection again, forever, while burning both sides' resources. So we treat non-throttling 4xx as terminal and move the token to a dead-letter state where a human can see it, rather than hammering the receiver. The carve-outs matter too: 408 (request timeout) and 429 (too many requests) are 4xx by number but transient by meaning, so they go to the retry path instead of the graveyard.

Retries use exponential backoff: a 30-second base, doubling per attempt, capped at one hour, with delivery attempts drawn from the queue under an advisory lock so two workers never fight over the same token. The cap matters because a receiver that is down for maintenance should see us back off to hourly instead of pounding its door every 30 seconds for a day.

Lesson 2: Strict receivers are right to distrust your algorithm

Because the signature is the only authentication, a receiver that is sloppy about verifying it has no security at all. The classic failure is algorithm confusion: a token arrives with "alg": "none", or with an alg the receiver did not expect, and a naive verifier either skips the check or uses the wrong key type to run it. A receiver that accepts alg: none accepts anything.

We learned this from the receiving side of our own product, and it shaped how we transmit. Our inbound verifier accepts only a small allowlist of algorithms, rejects alg: none outright, rejects any token carrying a crit header it does not understand, and requires the algorithm named in the header to match the key type it selected to verify with. Every one of those checks is a thing a good receiver does, which means every one is a constraint on what a transmitter can get away with. Your alg must be one the receiver actually implements. Your kid must point at a key the receiver can retrieve and it must be the key that actually signed the token. The receiver reads the kid from the header, fetches the corresponding public key, and verifies the ES256 signature. If any link in that chain is unfamiliar, a correct receiver rejects the token, and per Lesson 1 that rejection is terminal. Which led us straight into the hardest lesson.

Lesson 3: The field would not take our post-quantum signature

PasskeyBridge signs verifiable credentials with a hybrid scheme: a classical ES256 signature and a post-quantum ML-DSA-65 signature, so a credential stays verifiable even against an adversary with a quantum computer. We build hybrid signing on purpose, and we wanted to sign SETs the same way. We could not.

CAEP receivers in the field do not accept hybrid SETs. They expect exactly what the specifications describe: a single compact JWS, with an alg drawn from the small set of algorithms every JOSE library ships. Attach an unfamiliar algorithm identifier, or a second signature in a structure the receiver has never seen, and a strict receiver does the correct thing and rejects the token. The very paranoia we praised in Lesson 2 is what makes a post-quantum SET undeliverable today: there is no interoperable way to tell a receiver "verify this with two algorithms" that receivers have actually implemented.

So the transmitter signs SETs with classical ES256 only. The hybrid pathway stays where receivers already accept it, which is our own credential issuance, and the SET path waits for the ecosystem. This is the honest shape of running a standard in production: interoperating with receivers you do not control is a harder constraint than cryptographic ambition, and it wins. We wrote the post-quantum signing work up separately; the point here is that we deliberately did not use it on this path, and we can tell you exactly why. When the CAEP receiver ecosystem is ready for a hybrid or post-quantum SET, the signing code is already built. Until a receiver will verify it, shipping it would only produce tokens that dead-letter.

Lesson 4: Make the token safe to deliver twice

At-least-once delivery is the only kind you get across a network you do not own. A receiver returns a 2xx, the connection drops before we read it, and now we do not know whether it arrived. The safe move is to retry, which means the receiver will sometimes see the same event twice. If a duplicate SET causes a duplicate enforcement action, at-least-once delivery becomes a bug.

The fix is the jti. Every SET we emit carries a unique token id, and a correct receiver deduplicates on it. We know it works because we are also a receiver: on the inbound side we key on the pair of tenant and jti, and a repeat inside the idempotency window returns a plain 200 without re-firing the cascade, exactly so an upstream transmitter that retries does not double-enforce. As a transmitter, the lesson is to make every SET safely replayable: a stable jti, an event body that describes a state rather than a delta, and no assumption that the receiver saw it exactly once.

Lesson 5: Acknowledge after you act

The subtlest lesson is what a 2xx should mean on the receiver's end, because it determines what it means on ours. A receiver could acknowledge the moment the bytes arrive, before it has done anything. That makes the transmitter's life easy and the system unsafe: the receiver can ack and then crash before enforcing, and the event is silently lost with everyone believing it was delivered.

Our receiver does the opposite. It acknowledges after the enforcement cascade has dispatched, accepting a slower response in exchange for never losing an event it claimed. As a transmitter, that taught us to read a 2xx as "the receiver acted," and to treat our delivery timeout, which we cap at eight seconds, as unknown: the receiver may have enforced and been slow to answer, so we retry, and Lesson 4's idempotency makes that retry safe. The boundary between "acknowledged" and "timed out" is exactly where at-least-once delivery lives, and getting the acknowledgment semantics wrong on either end quietly turns "guaranteed" delivery into best-effort.

The subject never carries a name

One constraint runs underneath all of this: a SET must not leak who it is about. PasskeyBridge stores and transmits no low-entropy personal identifier in the clear, and SETs are no exception. The subject in every outbound SET is opaque, an identifier of the form { "format": "opaque", "id": "sha256:<hex>" }. The transmitter accepts only a pre-computed digest and only the opaque format, and rejects anything that looks like a raw email or phone number; the delivery queue stores that digest and nothing else. In the production signal path the digest is a keyed HMAC under a server-held pepper rather than a bare hash of an enumerable value, for the reasons we covered in why plain SHA-256 is not a privacy control. A receiver correlates our events against its own users by matching that opaque token, which it can only do if it already knows the mapping. The wire carries a correlator, never an identity. This is the same zero-PII posture as the rest of the platform, applied to the one place it is easiest to get wrong.

Left out on purpose

Candor, because a production writeup owes it. Push delivery per RFC 8935 is what we implement and run; poll-based delivery (RFC 8936) is advertised in our Shared Signals metadata but is not yet exposed as an endpoint. There is no mutual TLS; as covered above, the SET signature is the transmitter's only authentication to the receiver, which is the model the framework intends but is worth stating plainly. And the hybrid post-quantum signature is built and shipping on credentials only, and will stay that way until receivers can verify it. None of these is a gap we are hiding; they are the honest edges of running a young standard against receivers that are, correctly, conservative.

The mechanics of configuring a stream, the event catalog, and the exact delivery semantics are in the CAEP outbound transmitter guide. The conceptual case for why shared signals beat polling on revocation latency is in the SSF and CAEP explainer. And if you operate a receiver and want to argue with any of the five lessons above, that is the most useful email we could get: the standard only works if the transmitters and receivers building against it compare notes.

Start free · Test the API