Skip to main content

CAEP Outbound Transmitter (RFC 8935 Push Delivery)

How Security Event Tokens are signed and pushed to receivers: the queue, ES256 and ES384 per-tenant keys, the post-quantum proof, retries and dead letters.

Last reviewed September 29, 2026Current

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).

What a stream receives. Two things. Any event an admin emits by hand with emit_event, to every stream subscribed to it. And, on a stream whose events_requested names risk-level-change, one CAEP risk-level-change token for every identity signal the organization sends whose type the classifier knows: a hard signal (a SIM swap, a port-out, a device compromise, an account takeover) as current_level: "HIGH", a soft one (a behavioral, geo or velocity anomaly, a credential leak) as "MEDIUM". Publication is an explicit opt-in: a stream with an empty events_requested receives every emitted event but not these. Test-mode and sandbox signals are never published, and neither is a type outside the taxonomy.

The subject. A published token names the signal's subject_ref, your own opaque handle for the user, as its subject. A signal sent without subject_ref is not published: the only other identifier PasskeyBridge holds for it is the keyed phone digest, which is keyed under one platform-wide pepper, so sending it to receivers would let two organizations' receivers link the same person. The queue keeps no copy of the subject_ref: the worker reads it from the event when it signs, so erasing the subject (a data subject request) stops a token that has not yet been sent.

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)          signals you send (shield_events)
      |                                                      |
      v                                                      v   each run, first
shield-sse-caep  ->  fan out to matching          shield-sse-deliver: publish risk-level-change
                     transmitter streams          to streams that asked for it
      |                                                      |
      +---------------------> 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 RPC

shield_sse_outbound is the durable seam. Each queued row carries the event URI, the inline event payload, the retry bookkeeping (attempts, max_attempts, next_attempt_at and status) and how its subject is named: an emitted row carries the subject digest you supplied, and a published row carries source_event_id, the event it reports, instead. 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.

Publishing. Each run first reads, per stream that asked for risk-level-change, the events that arrived since the last run, a page of up to 500 at a time in arrival order, and queues one token for each classified signal with a subject_ref. A cursor per stream records the last event read, so a burst larger than a page is worked through over several runs. The run also re-reads the two minutes before the cursor, for an event whose row became visible after the previous run had read past its time. A token's jti is derived from the stream and the event and the queue is unique on the pair, so an event is queued once however often it is read. A stream's first run starts no more than ten minutes back, so opting in does not replay history.

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 of a published token:

{
  "iss": "https://api.passkeybridge.io/v1/tenants/acme",
  "iat": 1900000000,
  "jti": "5b2e7c4a-91d0-8f3e-a2b4-6c1d9e8f7a05",
  "aud": "https://receiver.example.com/caep",
  "txn": "3f6a1c2e-8b9d-4e0f-a1b2-c3d4e5f60718",
  "sub_id": { "format": "opaque", "id": "your-subject-ref" },
  "events": {
    "https://schemas.openid.net/secevent/caep/event-type/risk-level-change": {
      "principal": "USER",
      "current_level": "HIGH",
      "risk_reason": "SIM_SWAP_DETECTED",
      "event_timestamp": 1899999990,
      "initiating_entity": "policy",
      "reason_admin": { "en": "PasskeyBridge processed a hard sim_swap_detected signal" }
    }
  }
}

Every token names its subject in the top-level sub_id claim, which SSF 1.0 section 3.1 requires; until 2026-09-29 the subject sat only inside the event. A published token's txn is the id of the event it reports (SSF 1.0 section 4.1.9). An emitted token keeps the subject inside its event as well, which CAEP event types may do:

{
  "iss": "https://api.passkeybridge.io/v1/tenants/acme",
  "iat": 1900000000,
  "jti": "1f0c8a92-77d4-4c1e-9a3b-6e5f0d2c1b48",
  "aud": "https://receiver.example.com/caep",
  "sub_id": { "format": "opaque", "id": "9f2c41a8b7d3e5f0..." },
  "events": {
    "https://schemas.openid.net/secevent/caep/event-type/session-revoked": {
      "subject": { "format": "opaque", "id": "9f2c41a8b7d3e5f0..." },
      "reason_admin": "manual revocation"
    }
  }
}

No token carries sub or exp, which SSF forbids. 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": the signal's subject_ref on a published token, and on an emitted one the 64-character hex digest you supplied at emit_event, with no sha256: prefix, whose underlying identifier PasskeyBridge never sees.

kid is the tenant's active signing key id at the moment the row was claimed. Find it in the JSON Web Key Set at https://api.passkeybridge.io/v1/shield-sse-caep/jwks/{slug}, where the key's kid is this value, or in the tenant DID document at https://api.passkeybridge.io/v1/shield-did-resolve/{slug}, where the matching verificationMethod carries the same 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:

  1. 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 is in the JSON Web Key Set the transmitter metadata advertises as its jwks_uri, and in the tenant DID document. A tenant with no signing key yet gets one on its first delivery, at its tier's curve, as credential issuance and cascade receipts do.
  2. 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. A token whose proof cannot be made is not sent: it is held for the next run and counted as pqc_unsigned in the run summary.

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:

  1. Checks the stream is enabled and has a delivery endpoint.
  2. Validates the receiver URL against the SSRF policy.
  3. Signs the token and attaches the post-quantum proof headers.
  4. POSTs it with Content-Type: application/secevent+jwt and an eight-second timeout.
  5. Drains the response body and records the outcome.
Receiver responseOutcome
2xxdelivered, terminal
4xx other than 408 and 429dead_letter, read as a semantic rejection
5xx, 408, 429, a network error or the timeoutretry
attempts has reached max_attempts after a retrydead_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. So is a token with no subject left to name, subject_unavailable: a published row whose event, or its subject_ref, has been erased since it was queued. A failure to read or provision the tenant signing key, or to make the post-quantum proof, is a retry.

Each run returns and heartbeats a summary: processed, delivered, retried, dead_lettered, pqc_signed, pqc_unsigned, the run duration, and published: the streams that asked for risk-level-change, the events read, the tokens queued, the classified signals skipped for having no subject_ref, and the count of streams whose publication failed and is retried on the next run.

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 localhost or 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.
  • The name must resolve to public addresses only. The worker resolves it over DNS-over-HTTPS before every attempt, and an answer in any of those ranges is refused.

A refusal is terminal: the row is marked dead_letter with error set to ssrf: followed by the reason (invalid_url, non_https_scheme, blocked_hostname or private_ip_range), or dns: followed by resolves_private or unresolved. A resolver that cannot answer is dns:resolver_unavailable, a retry rather than a refusal.

The limit of this check. The name is resolved again on every attempt, which bounds a rebind that happens after configuration, but the connection makes its own lookup, so a record that changes between the check and the connection is not caught. 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 (null on a published row), source_event_id (the event a published row reports), 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 six transmitter 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.

Related from the blog