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 1.0 event name (session-revoked,session-established,session-presented,credential-change,assurance-level-change,token-claims-change,device-compliance-change,risk-level-change) or RISC name (account-disabled,account-purged,credential-compromise), plus'*'. Handlers receive the resolvedsubject, the raweventclaims,event_timestampand thejti.onEvent.verificationreceives an SSF 1.0 stream verification event (https://schemas.openid.net/secevent/ssf/event-type/verification) as{ state, jti, issuer }. The receiver answers202with or without the handler, and emits averificationaudit event carrying thestate, so the transmitter's verification request can be confirmed end to end.- The RISC events
account-disabled,account-purgedandcredential-compromiserevoke the whole account:revocationsgetssession-revokedfor the principal with no session, andapprovalscancels every pending request of the principal. 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 SETexp + clockTolerance, or for a SET withoutexpiat + maxTokenAge + clockTolerance, so a key outlives every moment its SET would still verify. The memory store evicts expired keys on read.maxTokenAge(optional, seconds, default 86400) bounds a SET withoutexp(RFC 8417 section 2.2 allows one): onceiatis older, the delivery is refused withinvalid_requestand causeexpired, so a captured SET cannot be replayed after its replay record is evicted.- A SET carrying several events records each event that succeeded. When a later handler fails, the receiver answers
500with an empty body, 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.
Transmitter
A service that emits signals uses the same entry. Each builder returns a SetEvent; signSet signs it, and pushSet delivers it or setPollHandler serves it.
import {
caepEvents,
memorySetQueue,
pushSet,
setPollHandler,
signSet,
} from "permdock/ssf";
const set = await signSet(
caepEvents.sessionRevoked({
subject: { format: "iss_sub", iss: issuer, sub: user.id },
initiatingEntity: "admin",
}),
signer,
{ audience: stream.aud },
);
const delivery = await pushSet(set, {
endpoint: stream.delivery.endpoint_url,
authorization: stream.delivery.authorization_header,
});
if (!delivery.ok && delivery.retry) {
await retryLater(set);
}
const queue = memorySetQueue();
await queue.enqueue(stream.stream_id, jti, set);
export const POST = setPollHandler({
queue,
authorize: (request) => streamForBearer(request.headers.get("authorization")),
});signSet(event, signer, { audience, jti?, txn?, issuedAt? })writessub_id,eventswith exactly one entry, andtxn;issis the signer's own.cloudSignerrefusessecevent+jwt: a SET signed with the environment's keys would read as one from the Cloud.pushSetnever throws. A2xxis{ ok: true }; a400maps the receiver's RFC 8935err(invalid_request,invalid_key,invalid_issuer,invalid_audience,authentication_failed,access_denied) to the kebab-casecause;401and403areauthentication-failedandaccess-denied; a network failure, a timeout, a429or a5xxisunavailablewithretry: true.setPollHandlerapplies the request'sacksandsetErrsto theSetQueue, then answers{ sets, moreAvailable? }with up tomaxEvents(default and cap 100). It answers at once whateverreturnImmediatelysays.authorizereturns the stream id the caller may poll, ornullfor401.SetQueueis an extension interface:enqueue,pending,ackandfail, each per stream.memorySetQueue()loses its SETs on restart; more than one instance needs a shared queue.streamVerificationEvent(stream, state)is the SSF 1.0 verification event a transmitter sends when a receiver calls its verification endpoint.SSF_PUSH_DELIVERYandSSF_POLL_DELIVERYare the delivery method URNs.
Transmitter endpoints
ssfTransmitter serves the SSF 1.0 stream-management API over an SsfStreamStore and a SetQueue, and fanOutSet sends one event to every stream that asked for it:
import {
caepEvents,
fanOutSet,
memorySetQueue,
memorySsfStreamStore,
ssfTransmitter,
} from "permdock/ssf";
const streams = memorySsfStreamStore();
const queue = memorySetQueue();
const transmitter = ssfTransmitter({
issuer: "https://signals.example.com",
endpoint: "https://signals.example.com/ssf",
streams,
queue,
signer,
authorize: (request) =>
receiverForBearer(request.headers.get("authorization")),
});
app.all("/ssf/*", (c) => transmitter(c.req.raw));
app.get("/.well-known/ssf-configuration", (c) => transmitter(c.req.raw));
await fanOutSet(
caepEvents.sessionRevoked({ subject, initiatingEntity: "admin" }),
{ streams, queue, signer },
);| Route | Method | SSF 1.0 |
|---|---|---|
/.well-known/ssf-configuration | GET, no credentials | Section 7.1 metadata: issuer, jwks_uri, both delivery methods, endpoints |
<endpoint>/stream | POST, GET, PATCH, PUT, DELETE | Section 8.1.1 stream configuration |
<endpoint>/status | GET, POST | Section 8.1.2 stream status |
<endpoint>/verify | POST | Section 8.1.4 verification: signs streamVerificationEvent and delivers it |
<endpoint>/poll?stream_id=<id> | POST | RFC 8936 poll through setPollHandler, for a poll stream |
authorize(request)answers the receiver the credentials authenticate, ornull(or a throw) for401. A receiver reads, changes and polls only the streams it created; another receiver's stream is404.POST /streamtakes the receiver-supplieddelivery,events_requestedanddescriptionand answers201with the full configuration. The transmitter sets the rest:stream_id(a UUID),iss,aud(audience(receiver), default the receiver),events_supported(eventsSupported, default every CAEP and RISC event the receiver handles) andevents_delivered, which isevents_requestedwithinevents_supported. Withoutdeliverya stream is poll, and a poll stream'sendpoint_urlis<endpoint>/poll?stream_id=<id>. A push stream needs an http or httpsendpoint_url.PATCHkeeps the receiver-supplied properties the body leaves out;PUTresets them to their defaults. Both name the stream in the body'sstream_id;GETandDELETEtake it as a query parameter, andGETwithout one lists the receiver's streams.- Every error is RFC 9457 Problem Details:
400with adetailnaming the property,404,405withAllow,409for verifying a disabled stream,413for a body over 64 KiB. fanOutSet(event, { streams, queue, signer, fetch? })reads every stream, keeps the ones that are notdisabledand listevent.typeinevents_delivered, and signs the event once per distinctaud. A push stream gets it throughpushSet; a poll stream, and a paused push stream, get it in the queue. It answers one{ stream, jti, queued, delivery }row per stream that took the event; a push withdelivery.retryis the caller's to retry.- A
pausedstream holds its SETs: a poll answers no sets, and a push stream's queue is pushed in order when it is enabled again.disabledandDELETEdrop the stream's pending SETs. SsfStreamStoreis an extension interface:create(owner, configuration),get,list(owner?),update(configuration),delete,statusandsetStatus.getandstatusanswernullfor an unknown stream, and a new stream isenabled.memorySsfStreamStore()loses its streams on restart.testSsfStreamStore(store)frompermdock/testingis its conformance suite.- Subjects are not managed per stream: the metadata says
default_subjects: "ALL", and the add and remove subject endpoints are not served.
Poll client
pollSets({ endpoint, authorization?, acks?, setErrs?, maxEvents?, returnImmediately? }) is one RFC 8936 poll request for a receiver that verifies SETs itself, such as a Cloud relaying another transmitter's stream. It answers { ok: true, sets: [{ jti, set }], moreAvailable } with the SETs unverified, or the same failure pushSet maps, plus invalid-request for a 2xx body without a sets object. It never throws. maxEvents defaults to 100 and 0 only acknowledges; returnImmediately defaults to true. receiver.poll makes the same requests.
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, 400 with error: invalid_request on a token that fails verification, as the specification requires, and 500 when the handler throws; 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 or RISC event URI maps to its short name, and the SSF verification URI toverification. - 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. A handler failure is a server error, not a malformed SET: push answers500with an empty body so the transmitter retries, and poll retains the event for redelivery. 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;Protectedshowsdeniedfor 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. - One SSF implementation for both roles. The transmitter's SETs are tested against this receiver in
tests/ssf/transmitter.test.ts, push and poll, so a SET PermDock Cloud emits is one the receiver is known to accept. - 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. The transmitter types the stream documents (SsfTransmitterConfiguration, SsfStreamConfiguration, SsfStreamStatus) and leaves the stream-management routes to the service that hosts them. 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.