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.

Last reviewed September 16, 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 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:

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

  1. Signature. The unverified iss is read only to locate this tenant's receiver stream for that issuer with status verified. 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.
  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 match the stream audience when one is configured, and exp and nbf are honoured with 60 seconds of skew.
  3. Deduplication. A jti already seen for this tenant answers 200 with status: "duplicate".
  4. Processing. Each entry under events is 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"
    }
  ]
}
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
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
Claims missing, or iat out of range400SET validation failed, with the reason
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 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_uri is https://api.passkeybridge.io/v1/shield-did-resolve/{slug}, the tenant's DID document. The classical signing key is the verificationMethod whose id matches the token header kid, 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_supported lists 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 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 credential_leak 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

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.

Related from the blog