Skip to main content

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.

Last reviewed October 27, 2026Fresh

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:

EventURI suffix
Session revokedsession-revoked
Token claims changetoken-claims-change
Credential changecredential-change
Assurance level changeassurance-level-change
Device compliance changedevice-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".

ActionBody beyond action and tenant_idResponse
list_streamsnoneThe tenant's streams, without stored secrets
list_eventslimit up to 200, offset, event_type, statusProcessed events, newest first
update_streamstream_id, plus any of delivery_endpoint_url, events_requested, events_delivered, issuer, audience, jwks_url, statusstatus: "updated"
delete_streamstream_idstatus: "deleted"
verify_streamstream_idstatus: "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:

  1. Signature. The unverified iss is read only to locate this tenant's receiver streams for that issuer with status verified (or enabled, for a verification event), and among them the one whose audience the token's aud names. The JWS is then verified against that stream's jwks_url. Accepted algorithms are ES256, ES384 and RS256; none and an unrecognised crit header are refused. The keyset is cached for five minutes, refetched once when a kid is unknown, and any fetch failure fails closed. The keyset is fetched only from a host that resolves to public addresses: a jwks_url that 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.
  2. Claims. iss, iat, jti and events are required. iat may be up to 60 seconds ahead of server time and up to 24 hours old. aud must name the stream's audience, and exp and nbf are honoured with 60 seconds of skew.
  3. Deduplication. Each event is recorded once per issuer, jti and event type. A token whose events were all recorded before answers 200 with status: "duplicate"; an event whose earlier delivery failed is run again.
  4. Processing. Each entry under events is 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.

FailureStatusMessage
No set_jwt401A signed SET (compact JWS) is required
Not a compact JWS, or no iss400SET is not a valid compact JWS or is missing the 'iss' claim
No verified receiver stream for that issuer403No verified receiver stream is configured for this issuer
The stream for that issuer has no audience403The 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 issuer403The SET's audience matches no receiver stream configured for this issuer
Receiver stream lookup failed503Receiver stream lookup failed
Signature fails401SET signature verification failed
Issuer or audience mismatch401SET 'iss' does not match the verified stream issuer
Transmitter JWKS unreachable502Could 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 address502The 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 range400SET validation failed, with the reason
A verification event with the wrong state400Verification event state does not match the state issued for this stream
An event's cascade failed500status: "partial", with the per-event results
Unknown tenant404Tenant not found
Over 60 requests a minute from one source429Rate 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/acme

The 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:

FieldValue
issuerhttps://api.passkeybridge.io/v1/tenants/{slug}, identical to the iss of the tokens the organization's streams send by default
jwks_urihttps://api.passkeybridge.io/v1/shield-sse-caep/jwks/{slug}, a JSON Web Key Set
delivery_methods_supportedurn:ietf:rfc:8935 (push) alone; poll delivery (RFC 8936) has no handler and is not advertised
configuration_endpoint, status_endpoint, verification_endpointhttps://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_subjectsALL
supported_eventsthe six event types a transmitter stream can carry: the five below plus risk-level-change
pb_receiver_eventsthe five event types this receiver processes
supported_subject_formatsthe seven subject formats an inbound token may use
spec_version1_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 eventTagDetail recorded
session-revokedsession_invalidation_queuedreason_admin
credential-changecredential_review_flaggedchange_type
assurance-level-changeassurance_level_updatedprevious and current level
token-claims-changeclaims_refresh_queuednone
device-compliance-changedevice_posture_reviewcurrent_status

Routing is deliberately conservative, escalating only when the inbound event unambiguously implies a broken trust assumption:

  • session-revoked maps to the hard signal account_takeover
  • credential-change maps to the hard signal account_takeover when change_type is revoke, rotate, compromise or delete, including their past-tense spellings, and to the soft signal behavioral_anomaly otherwise
  • device-compliance-change maps to the hard signal device_compromise when current_status is not-compliant or noncompliant, and to behavioral_anomaly otherwise
  • token-claims-change and assurance-level-change map to behavioral_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.

Related from the blog