SSE/CAEP: Shared Signals & Continuous Access Evaluation
PasskeyBridge as a Shared Signals transmitter and receiver: stream configuration, promoting a receiver stream to verified, the signed Security Event Token contract, subject hashing and the CAEP-to-cascade 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 the status column accepts enabled, paused, disabled and verified. A tenant admin promotes a stream by calling update_stream with status set to verified, which is a deliberate assertion by that admin: verify_stream records an attempt and changes no status. A token whose issuer has no promoted 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. 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 records a fresh last_verified_at and returns a random state value. It performs no handshake with the peer and it does not change status, so treat it as a bookkeeping call rather than proof the peer agrees.
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. A receiver stream needs issuer, jwks_url, a matching audience when you set one, and status of verified before an inbound token can verify against it. A tenant admin sets 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.
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, admin-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 stream for that issuer with statusverified. 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. - Claims.
iss,iat,jtiandeventsare required.iatmay be up to 60 seconds ahead of server time and up to 24 hours old.audmust match the stream audience when one is configured, andexpandnbfare honoured with 60 seconds of skew. - Deduplication. A
jtialready seen for this tenant answers 200 withstatus: "duplicate". - Processing. Each entry under
eventsis extracted, its subject digested, a row written, and the cascade routing run.
A processed token answers 200:
{
"status": "accepted",
"jti": "1f0c8a92-77d4-4c1e-9a3b-6e5f0d2c1b48",
"events_processed": [
{
"event_type": "session-revoked",
"status": "processed",
"event_id": "6d2e91c0-3b7a-4f55-8c19-2a0b7e4d1f36"
}
]
}| 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 |
| 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 |
Claims missing, or iat out of range | 400 | SET validation failed, with the reason |
| 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 promote the stream with 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
well_known returns Shared Signals transmitter metadata for one tenant. It needs no authentication.
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"}'The response advertises the tenant issuer https://api.passkeybridge.io/v1/tenants/{slug}, a jwks_uri, the supported delivery methods, the five CAEP event URIs, the seven subject formats, a spec_version of 1_0, and configuration, status, add-subject, remove-subject and verification endpoints that all point back at this same function.
The two entries a receiver builds against name what is served:
jwks_uriishttps://api.passkeybridge.io/v1/shield-did-resolve/{slug}, the tenant's DID document. The classical signing key is theverificationMethodwhose id matches the token headerkid, and the post-quantum key is the one ending#mldsa-1. The older.well-known/jwks/{slug}path is not routed, answers 404, and is no longer advertised.delivery_methods_supportedlists push (urn:ietf:rfc:8935) alone. Poll delivery (RFC 8936) has no handler and is not advertised.
The configuration and verification endpoints it names are real, and they take the JSON action bodies described above rather than the REST shapes the Shared Signals specification sketches.
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 signalcredential_leakwhenchange_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
A hard mapping runs the revocation cascade in-process and appends cascade_executed with the counts of delegates revoked, negotiations revoked, shadows frozen and tunnels torn down. 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.
The wiring is on by default and is switched off with the environment variable SSE_CASCADE_ENABLED set to false. Cascade failures are isolated per event: the event row is still written, the failure is recorded as cascade_dispatch_failed, and token receipt still answers 200, so a transmitter is never asked to retry because a downstream revocation had a bad day.