Shared Signals (SSF / CAEP)
permdock/ssf receives Shared Signals Framework security event tokens (push and poll) and maps CAEP events to snapshot and cache invalidation so permissions go stale when the IdP says so, not on a timer.
permdock/ssf is a receiver for Security Event Tokens delivered under the Shared Signals Framework. It verifies incoming CAEP events such as session-revoked, credential-change and assurance-level-change, resolves the affected subject, and calls the app's onEvent handlers, which typically invalidate the Next.js cache tag and client snapshots for that user.
Purpose
PermDock snapshots are cached: on the server behind use cache: private and updateTag, on the client in PermDockProvider, on React Native in persisted storage. Without a signal, a revoked session or a demoted role stays effective until the cache expires. Shared Signals Framework 1.0 and CAEP 1.0 (final since 2 September 2025, OpenID Foundation) define how identity providers transmit such events: Entra, Okta and Auth0 transmit today and Keycloak ships an experimental transmitter. A receiver in PermDock turns "stale for N minutes" into "stale until the IdP says so".
API
import { createPermDock } from "permdock/ssf";
import { updateTag } from "next/cache";
import { snapshotTag } from "permdock/next";
export const { receiver } = createPermDock(policy, {
issuer: "https://login.example.com", // expected SET issuer
audience: "https://app.example.com/ssf", // this receiver
jwks: "https://login.example.com/.well-known/jwks.json",
subject: (setSubject) => userIdFrom(setSubject), // maps the SET sub_id to a PermDock principal id
onEvent: {
"session-revoked": ({ subject }) => updateTag(snapshotTag(subject.id)),
"credential-change": ({ subject }) => updateTag(snapshotTag(subject.id)),
"assurance-level-change": ({ subject, event }) =>
updateTag(snapshotTag(subject.id)),
},
});
app.post("/ssf/events", (c) => receiver.push(c.req.raw)); // RFC 8935 push delivery
receiver.poll({ endpoint: "https://login.example.com/ssf/poll", every: "30s" }); // RFC 8936 poll deliveryreceiver.push(request)is a Fetch handler for RFC 8935 push delivery: it acceptsapplication/secevent+jwt, verifies, dispatches and returns202.receiver.poll(options)runs RFC 8936 poll delivery from a long-lived process: it fetches pending SETs, dispatches them and acknowledges byjti. Each poll request is aborted after 30 seconds, or earlier throughoptions.signal.onEventis keyed by CAEP event type. Handlers receive the resolvedsubject, the raweventclaims,event_timestampand thejti.subjectmaps the SETsub_id(formatsiss_sub,email,opaque,complex, and others defined by the Subject Identifiers RFC) to the id PermDock uses intagand snapshots. A SET withoutsub_idfalls back tosub, then to the event's ownsubjectmember (the pre-1.0 CAEP layout some transmitters still send). The session of acomplexsubject becomessubject.session, so asession-revokedevent ends only that session.verifier(optional, aTokenVerifierfrom extension interfaces) replaces the built-injoseTokenVerifieroverjwks;discovery: '<issuer>'may stand in forissuerplusjwksexactly as inpermdock/jwt; the document's issuer then becomes the expected SET issuer, for the built-in verifier and a custom one alike.replay(optional, aReplayStore) replaces the in-memoryjtistore.memoryReplayStore()is the default. Keys are opaque strings namespaced by issuer, so receivers for two IdPs can share one store without ajticollision.claim(key, expiresAt?)records a key and returnstrueonly for the first caller, andrelease(key)forgets it when a handler failed. A store with both makes concurrent deliveries of one SET dispatch once; a store with onlyseenandremember(key, expiresAt?)still works, but two simultaneous deliveries can both dispatch. The receiver passes the SETexporiat + clockTolerance. The memory store evicts expired keys on read.- A SET carrying several events records each event that succeeded. When a later handler fails, the receiver answers
400, the transmitter retries, and only the events that did not succeed are dispatched again. revocations(optional, aRevocationFeed) ends and revalidates open streams and sockets. After the handler succeeds, a verifiedsession-revoked(CAEP and Back-Channel Logout) publishessession-revokedfor the principal andsubject.session, which aborts that session's connections;credential-change,assurance-level-changeandtoken-claims-changepublishchangedfor the principal, which makes every connection it holds resolve its subject again. A failed handler publishes nothing, and a throwing feed never fails the delivery.approvals(optional, anApprovalStore) letssession-revoked(CAEP and Back-Channel Logout) callcancelApprovalsfor the principal andsubject.session. Pending requests are markedrejectedwithresolvedBy: 'system:ssf'. The receiver emits anapprovals-cancelledaudit event with the count.
Shared replay store
// Redis: SET NX EX is the atomic claim, TTL from expiresAt
const ttl = (expiresAt?: number) =>
expiresAt === undefined
? 3600
: Math.max(1, expiresAt - Math.floor(Date.now() / 1000));
const replay: ReplayStore = {
seen: async (key) => (await redis.exists(`ssf:${key}`)) === 1,
remember: async (key, expiresAt) => {
await redis.set(`ssf:${key}`, "1", { EX: ttl(expiresAt) });
},
claim: async (key, expiresAt) =>
(await redis.set(`ssf:${key}`, "1", { NX: true, EX: ttl(expiresAt) })) ===
"OK",
release: async (key) => {
await redis.del(`ssf:${key}`);
},
};
// Postgres: claim is INSERT … ON CONFLICT DO NOTHING RETURNING key; release is DELETEOpenID Connect Back-Channel Logout
The same receiver accepts an OpenID Connect Back-Channel Logout logout_token: a JWT with typ: logout+jwt, an events claim keyed http://schemas.openid.net/event/backchannel-logout, sub and/or sid, and no nonce. Mount it on the URL registered as the RP's backchannel_logout_uri:
app.post("/oidc/logout", (c) => receiver.logout(c.req.raw)); // application/x-www-form-urlencoded, logout_token=…receiver.logout verifies the token with the same TokenVerifier and iss / aud / iat / jti rules (Back-Channel Logout section 2.6), rejects one carrying nonce, and dispatches it as a session-revoked event whose subject is resolved from sub and whose session is sid. A handler that keys its cache on subject.session (OpenID Connect) invalidates exactly the session the OP ended; a handler keyed on subject.id invalidates every session for the user, which is the CAEP session-revoked behaviour and a safe default. The response is 200 on success and 400 with error: invalid_request on a token that fails verification, as the specification requires; no body is trusted to name the OP.
Back-Channel Logout and CAEP session-revoked are two deliveries of one signal, and PermDock treats them identically after verification: SSF for providers that transmit CAEP, Back-Channel Logout for the many OPs that implement only OIDC. Neither is a decision input; both invalidate.
Request lifecycle
- Delivery: a SET arrives by push (HTTP POST) or poll (fetched batch).
- Verification: signature against
jwks,issequalsissuer,audcontainsaudience,iatwithin tolerance,exphonoured when present and optional as RFC 8417 section 2.2 allows,jtinot seen before (replay store, in-memory by default, pluggable). - Parsing: the
eventsclaim is walked; each CAEP event URI maps to a short name (session-revoked,credential-change,assurance-level-change,token-claims-change,device-compliance-change). - Subject resolution:
sub_idis passed tosubject; unknown subjects are logged and acknowledged (an event for a user this app does not know is not an error). - Dispatch:
onEvent[type]runs. Typical handlers callupdateTagso the next navigation refetches the snapshot, and, when the app has a realtime channel, push aninvalidate()to connected clients. - Acknowledgement: push returns
202 Accepted; poll acknowledges processedjtivalues in the next request. Handler failures are reported per event (400with anerrbody for push, retained for redelivery for poll). A polled SET that fails verification or lacksjti,iatoreventscan never succeed, so the acknowledgement request reports it under RFC 8936setErrs({ err, description }byjti) and the transmitter stops redelivering it. on('decision')is not involved; the receiver emits its ownon('event')for audit.
What it validates
| Check | Failure |
|---|---|
JWS signature, alg allow-list | 400 invalid_key / invalid_request per RFC 8935 |
iss, aud, iat | 400 invalid_issuer / invalid_audience |
jti replay | acknowledged, not dispatched, logged |
| Event URI known | unknown events acknowledged and ignored unless onEvent['*'] is set |
sub_id format | supported formats mapped; others handed to subject raw |
logout_token: typ: logout+jwt, the backchannel-logout events key, sub or sid present, no nonce | 400 invalid_request (Back-Channel Logout section 2.8) |
The receiver never trusts the SET body to identify the transmitter; the signature and issuer do. It does not accept unsigned or none-algorithm tokens, and its algorithm allow-list is the permdock/jwt one (Ed25519, ES256, PS256, RS256; polymorphic EdDSA accepted for a crv: Ed25519 key with a permdock doctor warning).
CAEP Interoperability Profile
SSF 1.0 and CAEP 1.0 leave a transmitter and a receiver several choices: which event types to emit, which delivery method to offer, which subject identifier formats to use. Two conformant implementations can therefore fail to interoperate. The OpenID Foundation's Shared Signals Working Group published the CAEP Interoperability Profile 1.0 as an implementer's draft in July 2026 to close that gap: it fixes the subset of CAEP event types and the delivery behaviour that an interoperable transmitter must produce and an interoperable receiver must accept.
permdock/ssf targets the receiver side of the profile:
- The event types the profile requires are the ones
onEventis keyed by; the receiver accepts them without configuration and treats profile-required delivery as the baseline rather than an option. - Events and subject identifier formats outside the profile still work through
onEvent['*']and thesubjectmapper, so a transmitter that emits more than the profile is not rejected. packages/permdock/tests/testing/fixtures/ssf/transmitters.jsonholds SETs in the shapes Entra, Okta and Auth0 transmit, andssf-transmitters.test.tsreplays them against the receiver with no network, so a transmitter or profile change that alters a required shape shows up as a failing fixture rather than a silent drop.
The profile is an implementer's draft; its row on the standards watch list is reviewed each release, and this section is updated when the profile reaches final.
How denials surface
This adapter does not make permission decisions, so there are no denials in the PermDock sense. Its effect is indirect and visible in three places:
- Server: after
updateTag,getPermDock()andgetPermission()compute from a fresh snapshot on the next request; a revoked session resolves to an anonymous subject and every check is denied. - Client:
usePermissionreportsstatus: 'stale'until the new snapshot arrives, thenallowedflips;Protectedshowsfallbackfor permissions that were removed. - Audit:
on('event')records event type, subject id, transmitter and the tags invalidated, so a revocation and the subsequent denials can be correlated.
Delivery failures return the RFC 8935 error object (err, description) so the transmitter retries.
Example app
None. The receiver is exercised against the recorded Entra, Okta and Auth0 SETs in permdock/testing. The Next.js example (apps/examples/next) documents mounting receiver.push as a route handler.
Why
- Recorded SETs, not live transmitters. The receiver's risk is shape drift: a transmitter that puts the subject somewhere new, or a session in a
complexidentifier, turns a revocation into a silently acknowledged unknown subject. Replaying each transmitter's shape as a committed fixture catches that on everypnpm testwithout tenants, secrets or network. The signature is re-created with a test key because the transmitters' keys are private; signature verification is covered by thepermdock/jwtsuites. - Keycloak waits. Its transmitter is experimental and the only self-hostable one, so a container suite would test a moving target. Its SETs join the fixture file once the transmitter is stable.
Related standards
- Shared Signals and CAEP: SSF 1.0, CAEP 1.0 event catalogue, RFC 8935 push, RFC 8936 poll, transmitters.
- OpenID Connect: Back-Channel Logout 1.0 as the second revocation input,
sidassubject.session. - JOSE: the
TokenVerifierboth inputs are verified with;secevent+jwtandlogout+jwtas registeredtypvalues. - Standards watch list: the CAEP Interoperability Profile, OpenID Provider Commands and
session_expiryrows that affect snapshot freshness. - Snapshots: what gets invalidated.
- Next.js adapter:
tagandupdateTagintegration. - React adapter: client
invalidateandstalestatus.
assurance-level-change is invalidation only. The next subjectFrom* carries the new acr / amr; assurance() on the grant decides. The event never raises a policy.
The receiver does not manage streams: creating the stream at the transmitter and add_subject stay in the IdP console. It also pushes nothing to clients; an onEvent handler that has a realtime channel sends invalidate() itself, and server-side streams and sockets end through revocations.
Last updated on
Cloud integrations
The connectors PermDock Cloud offers on its Integrations page, what each one speaks and what it never does; every connector is a standard wire format or an existing PermDock interface, none is an npm entry, and none sits on the decision path.
SCIM
permdock/scim is an RFC 7644 receiver for Users and Groups provisioned by Okta, Entra ID or Google Workspace; it writes to a DirectoryStore you own and exposes the synced groups as a MembershipSource, so deprovisioning and group-to-role changes reach decisions without a token refresh and without the Cloud on the decision path.