SSE/CAEP: Shared Signals & Continuous Access Evaluation
PasskeyBridge as a Shared Signals transmitter and receiver: stream setup, verifying a receiver stream, the SET contract, subject hashing, CAEP mapping.
Shared Signals and CAEP on PasskeyBridge
The OpenID Shared Signals Framework and the Continuous Access Evaluation Protocol let identity systems push security events to each other as signed Security Event Tokens (RFC 8417), rather than waiting for the next token refresh. PasskeyBridge implements both sides in one function, shield-sse-caep: it pushes tokens to receivers you configure, and it consumes tokens from an external transmitter.
The receiving side is switched on per stream. An inbound token is accepted only against a receiver stream whose status is verified and whose audience the token's aud names; the status column accepts enabled, paused, disabled and verified. A stream becomes verified through the Shared Signals verification exchange: verify_stream hands out a state, the transmitter echoes it in a verification event on the stream, and the stream moves to verified when the echo matches. A tenant admin can also set status to verified with update_stream, a deliberate assertion by that admin. A token whose issuer has no verified stream answers 403 "No verified receiver stream is configured for this issuer." The transmitting side is separate and is documented in CAEP outbound transmitter.
The five CAEP event types the function knows:
| Event | URI suffix |
|---|---|
| Session revoked | session-revoked |
| Token claims change | token-claims-change |
| Credential change | credential-change |
| Assurance level change | assurance-level-change |
| Device compliance change | device-compliance-change |
On the wire each is the full URI, https://schemas.openid.net/secevent/caep/event-type/ followed by the suffix.
Where it sits. shield-sse-caep answers at https://api.passkeybridge.io/v1/shield-sse-caep. Stream management takes a dashboard session JWT, with tenant membership to read and tenant admin to change anything. Token receipt is unauthenticated at the transport and authorized by the token signature. The metadata action is public. There is no API-key lane on this function and no SSE scope to mint.
Architecture and data model
Three tables carry the state.
shield_sse_streams holds one row per configured stream: direction (transmitter or receiver), delivery_method, delivery_endpoint_url, the events subscribed, issuer, audience, jwks_url, status and last_verified_at.
shield_sse_events holds one row per processed event: the event type, the keyed subject digest, the issuer, the audience, direction, actions_taken, and the raw token encrypted with AES-256-GCM for forensic retention.
shield_sse_outbound is the transmitter's delivery queue, documented in the transmitter guide.
Zero-PII handling. A CAEP subject identifier is usually an email address or a phone number, and a plain digest of either is reversible by enumeration, so subjects are digested with HMAC-SHA-256 under a server-held pepper, with the subject format bound into the message. The same address under two formats therefore produces unrelated digests. All seven Shared Signals subject formats are recognised: email, iss_sub, opaque, phone, did, uri and aliases. An event whose subject cannot be parsed is stored under a digest derived from the token id, so it is never silently attributed to another subject.
Outbound token signatures. Each token PasskeyBridge pushes is a compact JWS signed with the tenant's ES256 or ES384 key, with kid naming the tenant's active key. The request also carries a detached ML-DSA proof over the JWS signing input in the x-pb-pqc-signature, x-pb-pqc-algorithm, x-pb-pqc-nonce and x-pb-pqc-kid headers. The verification recipe is the one under outbound webhook signatures, with the signed input being the JWS header.payload instead of the body. Receivers that verify the JWS alone are unaffected.
Stream management
Stream actions POST to https://api.passkeybridge.io/v1/shield-sse-caep with Authorization: Bearer and a dashboard session JWT, plus a tenant_id in the body. list_streams and list_events need tenant membership; every other action needs tenant admin. API keys are not accepted here.
{
"action": "configure_stream",
"tenant_id": "8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"config": {
"stream_uri": "https://idp.example.com/.well-known/sse-configuration",
"direction": "receiver",
"delivery_method": "push",
"delivery_endpoint_url": "https://receiver.example.com/caep",
"events_requested": ["session-revoked", "credential-change"],
"issuer": "https://idp.example.com",
"audience": "https://api.passkeybridge.io",
"jwks_url": "https://idp.example.com/.well-known/jwks.json"
}
}stream_uri is required, and direction must be transmitter or receiver. A receiver stream also needs audience, the value the transmitter puts in each token's aud, which is what binds a token to your organization; without it the call answers 400. Everything else is optional. Entries in events_requested survive only when they match a known CAEP event URI or its suffix, so a typo is dropped silently: read the stream back and check. New streams are created with status enabled, and the response is the new stream id with status: "configured".
| Action | Body beyond action and tenant_id | Response |
|---|---|---|
list_streams | none | The tenant's streams, without stored secrets |
list_events | limit up to 200, offset, event_type, status | Processed events, newest first |
update_stream | stream_id, plus any of delivery_endpoint_url, events_requested, events_delivered, issuer, audience, jwks_url, status | status: "updated" |
delete_stream | stream_id | status: "deleted" |
verify_stream | stream_id | status: "verification_initiated", with a state value and the stream URI |
verify_stream starts the Shared Signals verification exchange (SSF 1.0 section 7.1). It returns a random state and keeps only its digest on the stream. Have the transmitter send a verification event (https://schemas.openid.net/secevent/ssf/event-type/verification) carrying that state, signed and addressed like any other token; an enabled stream accepts that one event type. When the state matches, the stream moves to verified, last_verified_at is set, and the event answers 202 with stream_verified: true. Any other state answers 400.
delete_stream removes the stream row. Its processed events survive with stream_id set to null, and any queued outbound rows for that stream are deleted with it.
Promoting a receiver stream by hand. A receiver stream needs issuer, jwks_url, audience and status of verified before an inbound token can verify against it. When the transmitter cannot send a verification event, a tenant admin can set the status with update_stream:
{
"action": "update_stream",
"tenant_id": "8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"stream_id": "3c7d9e1f-2a4b-5c6d-7e8f-9a0b1c2d3e4f",
"status": "verified"
}The four accepted values are enabled, paused, disabled and verified; a database constraint refuses anything else and the call answers 500 Internal server error. Read the stream back with list_streams and check status before you ask the transmitter to send. Every mutation writes an audit row: sse.stream.create, sse.stream.update, sse.stream.delete or sse.stream.verify_requested, and a completed verification exchange writes sse.stream.verified.
Security Event Token ingestion
An inbound token can drive revocation, so the gate is fail-closed: it must be a signed compact JWS that verifies against a configured, verified receiver stream.
curl -X POST https://api.passkeybridge.io/v1/shield-sse-caep \
-H "content-type: application/json" \
-d '{
"action": "receive_set",
"tenant_id": "8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"set_jwt": "eyJhbGciOiJFUzI1NiIsInR5cCI6InNlY2V2ZW50K2p3dCJ9.eyJpc3MiOi4uLn0.MEUCIQ"
}'tenant_id may instead travel in x-pb-tenant-id. There is no set field: a token supplied as bare JSON is refused with 401.
The pipeline, in order:
- Signature. The unverified
issis read only to locate this tenant's receiver streams for that issuer with statusverified(orenabled, for a verification event), and among them the one whoseaudiencethe token'saudnames. The JWS is then verified against that stream'sjwks_url. Accepted algorithms are ES256, ES384 and RS256;noneand an unrecognisedcritheader are refused. The keyset is cached for five minutes, refetched once when akidis unknown, and any fetch failure fails closed. The keyset is fetched only from a host that resolves to public addresses: ajwks_urlthat is not HTTPS, or whose host is or resolves to a private, loopback, link-local or metadata address, is refused before any connection is made, and the refusal is written to the platform's egress denial log. - Claims.
iss,iat,jtiandeventsare required.iatmay be up to 60 seconds ahead of server time and up to 24 hours old.audmust name the stream's audience, andexpandnbfare honoured with 60 seconds of skew. - Deduplication. Each event is recorded once per issuer,
jtiand event type. A token whose events were all recorded before answers 200 withstatus: "duplicate"; an event whose earlier delivery failed is run again. - Processing. Each entry under
eventsis extracted, its subject digested, a row written, and the cascade routing run.
A processed token answers 202:
{
"status": "accepted",
"jti": "1f0c8a92-77d4-4c1e-9a3b-6e5f0d2c1b48",
"events_processed": [
{
"event_type": "session-revoked",
"status": "processed",
"event_id": "6d2e91c0-3b7a-4f55-8c19-2a0b7e4d1f36"
}
]
}When an event's cascade fails, that event is marked failed and the token answers 500 with status: "partial" and the per-event results, so the transmitter redelivers it (RFC 8935 section 2.3). On the redelivery the processed events answer duplicate and only the failed ones run again.
| Failure | Status | Message |
|---|---|---|
No set_jwt | 401 | A signed SET (compact JWS) is required |
Not a compact JWS, or no iss | 400 | SET is not a valid compact JWS or is missing the 'iss' claim |
| No verified receiver stream for that issuer | 403 | No verified receiver stream is configured for this issuer |
The stream for that issuer has no audience | 403 | The receiver stream for this issuer has no audience configured; set one and address the SET to it |
The token's aud names no stream for that issuer | 403 | The SET's audience matches no receiver stream configured for this issuer |
| Receiver stream lookup failed | 503 | Receiver stream lookup failed |
| Signature fails | 401 | SET signature verification failed |
| Issuer or audience mismatch | 401 | SET 'iss' does not match the verified stream issuer |
| Transmitter JWKS unreachable | 502 | Could not fetch the transmitter JWKS |
The stream's jwks_url is not HTTPS, or its host is or resolves to a private, loopback, link-local or metadata address | 502 | The transmitter JWKS URL is refused: it must use HTTPS and name a host that resolves only to public addresses |
Claims missing, or iat out of range | 400 | SET validation failed, with the reason |
| A verification event with the wrong state | 400 | Verification event state does not match the state issued for this stream |
| An event's cascade failed | 500 | status: "partial", with the per-event results |
| Unknown tenant | 404 | Tenant not found |
| Over 60 requests a minute from one source | 429 | Rate limited |
The 403 is the boundary to build a new receiver integration against: configure the stream, send a token, see the refusal, then verify the stream (the exchange above, or update_stream) and send the token again. Confirm receipt with list_events, where the processed token appears with its jti, its event type, the keyed subject digest and its actions_taken.
Transmitter metadata
An organization's Shared Signals transmitter metadata (SSF 1.0 section 7) is public and needs no authentication. A receiver that follows the specification fetches it from the location SSF derives from the issuer:
curl https://api.passkeybridge.io/.well-known/ssf-configuration/v1/tenants/acmeThe same document is served at GET https://api.passkeybridge.io/v1/shield-sse-caep/ssf-configuration/{slug}, and by the well_known action by organization id:
curl -X POST https://api.passkeybridge.io/v1/shield-sse-caep \
-H "content-type: application/json" \
-d '{"action":"well_known","tenant_id":"8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f"}'It carries:
| Field | Value |
|---|---|
issuer | https://api.passkeybridge.io/v1/tenants/{slug}, identical to the iss of the tokens the organization's streams send by default |
jwks_uri | https://api.passkeybridge.io/v1/shield-sse-caep/jwks/{slug}, a JSON Web Key Set |
delivery_methods_supported | urn:ietf:rfc:8935 (push) alone; poll delivery (RFC 8936) has no handler and is not advertised |
configuration_endpoint, status_endpoint, verification_endpoint | https://api.passkeybridge.io/v1/shield-sse-caep, taking the JSON action bodies described above rather than the REST shapes of SSF section 8; pb_action_envelope: true says so |
default_subjects | ALL |
supported_events | the six event types a transmitter stream can carry: the five below plus risk-level-change |
pb_receiver_events | the five event types this receiver processes |
supported_subject_formats | the seven subject formats an inbound token may use |
spec_version | 1_0 |
Add-subject and remove-subject endpoints are not offered and not advertised.
Signing keys. The key set at jwks_uri lists the organization's active ECDSA keys, each with kid equal to the kid in a token's header, so a standard JOSE library verifies a token without knowing about DIDs. The same keys are in the organization's DID document at https://api.passkeybridge.io/v1/shield-did-resolve/{slug}, which also publishes the post-quantum key, ending #mldsa-1, that verifies the detached ML-DSA proof a delivery carries. Until 2026-09-29 jwks_uri named the DID document, which a standard receiver cannot read keys from.
CAEP event actions
Each processed event records two layers in actions_taken: a tag for the event type, and the cascade routing outcome when the event maps to a known signal class.
| CAEP event | Tag | Detail recorded |
|---|---|---|
session-revoked | session_invalidation_queued | reason_admin |
credential-change | credential_review_flagged | change_type |
assurance-level-change | assurance_level_updated | previous and current level |
token-claims-change | claims_refresh_queued | none |
device-compliance-change | device_posture_review | current_status |
Routing is deliberately conservative, escalating only when the inbound event unambiguously implies a broken trust assumption:
session-revokedmaps to the hard signalaccount_takeovercredential-changemaps to the hard signalaccount_takeoverwhenchange_typeis revoke, rotate, compromise or delete, including their past-tense spellings, and to the soft signalbehavioral_anomalyotherwisedevice-compliance-changemaps to the hard signaldevice_compromisewhencurrent_statusisnot-compliantornoncompliant, and tobehavioral_anomalyotherwisetoken-claims-changeandassurance-level-changemap tobehavioral_anomaly
Each event names a signal type; whether it is hard or soft is the classifier's answer for that type, the same one shield-cascade and signal ingest give, so a CAEP event cannot revoke under a type the rest of the platform treats as soft. The CAEP detail, such as credential-change:revoke, is kept as the routing reason.
A hard mapping runs subject-scoped shadow freezing in-process. Delegates, negotiations and tunnels have no subject linkage and remain unchanged; their returned counts are zero and the cascade audit metadata lists them under unscoped_skipped. An empty subject scope fails instead of becoming an organization-wide action. The event records cascade_executed and the receipt's jti. A soft mapping appends cascade_soft_routed and dispatches nothing. A cascade also writes a cascade_execute audit row naming the token issuer as actor with triggered_by: caep, so the timeline threads with the history described in Cascade classification. It is given the same signed receipt a shield-cascade run is, kept for the organization to read with cascade_receipt, because this response goes to the transmitter.
The wiring is on by default and is switched off with the environment variable SSE_CASCADE_ENABLED set to false. A cascade failure is recorded per event as cascade_dispatch_failed, the event row is marked failed, and the token answers 500 so the transmitter redelivers it; the redelivery runs only the failed events.