CAEP Outbound Transmitter (RFC 8935 Push Delivery)
How PasskeyBridge signs and pushes Security Event Tokens to third-party receivers: the queue, ES256 and ES384 signing with per-tenant keys, the detached post-quantum proof, retry and dead-letter behaviour, and how to operate it.
Overview
The CAEP outbound transmitter is the push side of PasskeyBridge's Shared Signals support. When a tenant stream is configured with direction: "transmitter" and a delivery endpoint, a matching event is signed as a Security Event Token (RFC 8417) and pushed to that receiver over HTTP (RFC 8935).
Both sides ship. The receiving side is switched on per stream by promoting a receiver stream to the verified status, described in Shared Signals and CAEP.
Who can use it. A tenant admin with a dashboard session. Streams are configured and the queue is inspected through shield-sse-caep, which takes a session JWT and accepts no API key. The delivery worker is cron-only and reachable by nobody else. Neither function carries a plan gate in code.
This guide documents the shipped pipeline: how events enter the queue, how they are signed, how delivery is retried, and how to operate a receiver against it.
End-to-end data flow
emit_event (tenant admin, dashboard session)
|
v
shield-sse-caep -> fan out to matching transmitter streams -> shield_sse_outbound (pending)
|
every 5 minutes v
shield-sse-deliver
|
+--------------------+--------------------+--------------------+
v v v v
sign ES256/ES384 attach ML-DSA proof POST compact token mark result
with the tenant key headers to the receiver through an RPCshield_sse_outbound is the durable seam. Each queued row carries the subject digest, the event URI, the inline event payload, and the retry bookkeeping: attempts, max_attempts, next_attempt_at and status. Once a row is signed it also records the entropy seed hash behind its post-quantum nonce, which is what an entropy provenance receipt resolves later.
A row is signed with whichever tenant key is active when it is claimed for delivery, so a rotation that happens between queueing and delivery is picked up by the delivery instead of being baked in at fan-out.
Security event token shape
Each delivered token is a compact JWS. The header:
{
"alg": "ES256",
"typ": "secevent+jwt",
"kid": "the tenant's active key id"
}The payload:
{
"iss": "https://api.passkeybridge.io/v1/tenants/acme",
"iat": 1900000000,
"jti": "1f0c8a92-77d4-4c1e-9a3b-6e5f0d2c1b48",
"aud": "https://receiver.example.com/caep",
"events": {
"https://schemas.openid.net/secevent/caep/event-type/session-revoked": {
"subject": { "format": "opaque", "id": "9f2c41a8b7d3e5f0..." },
"reason_admin": "manual revocation"
}
}
}iss falls back to the tenant issuer URI when the stream declares no issuer of its own, and aud falls back to the delivery endpoint URL when no audience is configured.
alg is ES256 for a P-256 tenant key and ES384 for the P-384 key that ML-DSA-87 tenants hold. The header always names the algorithm that actually signed: a mismatch between the two fails the delivery rather than shipping a mislabelled signature.
The subject is always format: "opaque", with id set to the 64-character hex digest you supplied at emit_event. There is no sha256: prefix on that value, and PasskeyBridge never sees the identifier behind it.
kid is the tenant's active signing key id at the moment the row was claimed. Resolve it in the tenant DID document at https://api.passkeybridge.io/v1/shield-did-resolve/{slug}, where the matching verificationMethod carries the public key. Key management has the rotation contract.
Signing keys and algorithms
Signing uses the tenant's own ECDSA key. The private key is decrypted from the vault once per delivery batch and cached only for the life of that batch, and it never leaves the function process.
Two consequences are worth stating plainly:
- Receivers verify against that tenant's published key, never a platform-wide one, so compromise of one tenant's key cannot forge another tenant's events. The public key lives in the tenant DID document at
https://api.passkeybridge.io/v1/shield-did-resolve/{slug}, which is the URL the transmitter metadata advertises as itsjwks_uri. - A key rotation takes effect at the next batch boundary. Rows signed under the previous key stay valid until that key's grace window ends.
Beside the JWS, every delivery carries a detached ML-DSA signature over the same signing input, the header.payload string, in the x-pb-pqc-signature, x-pb-pqc-algorithm, x-pb-pqc-nonce and x-pb-pqc-kid headers. Verify it against the key ending #mldsa-1 in the same DID document; the recipe is under outbound webhook signatures. A receiver that verifies the JWS alone is unaffected, and a failure to produce the proof is counted in the run summary rather than blocking the delivery.
Delivery semantics
shield-sse-deliver runs every five minutes under pg_cron. The job name still reads shield-sse-deliver-every-minute, from a schedule that has since changed; the schedule is what governs.
Each run claims up to 100 due rows atomically through an RPC that takes an advisory lock and selects FOR UPDATE SKIP LOCKED, flipping them to delivering before any HTTP work. For each row it then:
- Checks the stream is
enabledand has a delivery endpoint. - Validates the receiver URL against the SSRF policy.
- Signs the token and attaches the post-quantum proof headers.
- POSTs it with
Content-Type: application/secevent+jwtand an eight-second timeout. - Drains the response body and records the outcome.
| Receiver response | Outcome |
|---|---|
| 2xx | delivered, terminal |
| 4xx other than 408 and 429 | dead_letter, read as a semantic rejection |
| 5xx, 408, 429, a network error or the timeout | retry |
attempts has reached max_attempts after a retry | dead_letter |
Backoff is computed when the row is claimed: the next attempt falls due 30 seconds multiplied by two to the power of the attempts so far, capped at one hour. With max_attempts defaulting to 5, every one of those floors is shorter than the gap between runs, so a persistently failing delivery gets five attempts about five minutes apart and is dead-lettered roughly twenty minutes after it was queued.
Misconfiguration is recorded rather than retried forever. A stream that is not enabled or has no endpoint, and an SSRF refusal, are terminal and write an explicit error string on the row. A missing tenant signing key is a retry, since provisioning one fixes it.
Each run returns and heartbeats a summary: processed, delivered, retried, dead_lettered, pqc_signed, pqc_unsigned and the run duration.
SSRF checks on receiver URLs
Receiver URLs are admin-configured, so the worker deliberately bypasses the platform-wide egress allowlist: a CAEP receiver is a third-party host by nature, and allowlisting every customer's endpoint is impractical. Each URL is checked instead, before every attempt:
- The scheme must be
https:. - The hostname must not be
localhostor a known cloud metadata name. - The hostname must not be a literal address in a loopback, RFC 1918, link-local, carrier-grade NAT, multicast, or IPv6 unique-local or link-local range.
A refusal is terminal: the row is marked dead_letter with error set to ssrf: followed by the reason, one of invalid_url, non_https_scheme, blocked_hostname or private_ip_range.
The limit of this check. It is a string test against the hostname, re-run on every attempt. It resolves no DNS, so a public hostname whose address record points into a private range passes it. Re-checking per attempt bounds a rebind that happens after configuration, and it is not a defence against a stream deliberately configured to point inward. The real control is that only a tenant admin can configure a stream.
A receiver inside a corporate network needs an internet-reachable HTTPS endpoint: an API gateway, a managed ingress, or a relay that forwards inward.
Operating the queue
The worker is cron-only. It authenticates with a shared x-cron-secret header and runs with service-role privileges so a single pass drains every tenant's queue, and it writes a heartbeat on every run, including empty ones, so a stalled worker is visible to the operational alerter rather than indistinguishable from a quiet queue.
Inspect the queue with the list_outbound action, which needs a tenant-admin session JWT and a tenant_id:
curl -X POST https://api.passkeybridge.io/v1/shield-sse-caep \
-H "Authorization: Bearer YOUR_TENANT_ADMIN_SESSION_JWT" \
-H "content-type: application/json" \
-d '{"action":"list_outbound","tenant_id":"8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f","status":"failed","limit":50}'Rows come back with jti, event_type, subject_identifier_hash, status, attempts, max_attempts, next_attempt_at, last_attempt_at, last_status_code, last_error and delivered_at. limit is capped at 200, offset pages through, and status accepts pending, delivering, delivered, failed or dead_letter.
To put a real event through the pipeline, call emit_event with the same authorization:
{
"action": "emit_event",
"tenant_id": "8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"event_type": "session-revoked",
"subject_identifier_hash": "9f2c41a8b7d3e5f0a1b2c3d4e5f60718293a4b5c6d7e8f9012a3b4c5d6e7f801",
"subject_format": "opaque",
"event_data": { "reason_admin": "manual test" }
}event_type is the short label, one of the five CAEP types; anything else answers 400. subject_identifier_hash must be exactly 64 hex characters, which you compute and PasskeyBridge stores and transmits unchanged. subject_format must be opaque, the only format the transmitter supports today. event_data is merged into the event body beside the subject.
The response reports status: "queued" with a count and the new token ids, one per matching transmitter stream, or status: "no_subscribers" with a count of zero when no enabled transmitter stream subscribes to that event type. Delivery happens on the next worker run, so a queued row sitting in list_outbound as pending is the expected state for up to five minutes.
Poll delivery and mutual TLS
Two things a receiver might expect are absent.
Poll delivery. Only HTTP push (RFC 8935) is served, and the transmitter metadata advertises urn:ietf:rfc:8935 alone in delivery_methods_supported. There is no poll endpoint (RFC 8936), so a receiver that cannot accept a push needs a relay of its own.
Mutual TLS. There is no client certificate on the outbound connection. PasskeyBridge authenticates to your receiver with the token signature alone: verify the JWS against the tenant key published in the DID document, and optionally the detached ML-DSA proof in the x-pb-pqc-* headers.
For completeness, because earlier drafts of this page said otherwise: the post-quantum co-signature ships today, on every token this worker sends.