PermDock
Standards

Shared Signals and CAEP

How the OpenID Shared Signals Framework 1.0 and CAEP 1.0 let permdock/ssf invalidate snapshots the moment an identity provider revokes a session instead of waiting for a TTL.

What it is

The OpenID Foundation's Shared Signals Framework (SSF) 1.0 and Continuous Access Evaluation Profile (CAEP) 1.0 were approved as Final Specifications on 2 September 2025 (announcement). SSF is the plumbing: a transmitter (usually an identity provider) sends Security Event Tokens (SETs) to a receiver (an application or another IdP) over either RFC 8935 push (the transmitter POSTs to the receiver) or RFC 8936 poll (the receiver fetches). CAEP is the vocabulary of events about a session's continued validity, including:

  • session-revoked: the user's session at the IdP ended (sign-out, admin action, risk signal).
  • credential-change: a password, key or MFA factor was changed, added or removed.
  • assurance-level-change: the user's authentication assurance went up or down.

Transmitters in production include Microsoft Entra, Okta and Auth0; Keycloak shipped an experimental SSF transmitter in July 2026. PermDock's receiver is tested against SETs in the Entra, Okta and Auth0 shapes; Keycloak joins when its transmitter leaves experimental status (SSF adapter).

Why it matters for PermDock

PermDock's client model is snapshot-based: permdock.snapshot() serialises roles and grants, the React and React Native adapters answer checks from it, and Next.js caches it with "use cache: private" and a tag. A snapshot is only as fresh as its TTL. Without SSF, revoking a user's session at the IdP leaves a stale snapshot granting UI access until the cache expires. With an SSF receiver, the IdP tells PermDock the moment something changes, and PermDock invalidates exactly that user's snapshot and client cache. The snapshot model moves from "stale for N minutes" to "stale until the IdP says so". See snapshots.

How PermDock uses it

import { createPermDock } from "permdock/ssf";
import { updateTag } from "next/cache";
import { snapshotTag } from "permdock/next";

export const { receiver } = createPermDock(policy, {
  onEvent: {
    "session-revoked": ({ subject }) => updateTag(snapshotTag(subject.id)),
    "credential-change": ({ subject }) => updateTag(snapshotTag(subject.id)),
    "assurance-level-change": ({ subject, event }) => audit(event),
  },
});
// Mount `receiver` as the RFC 8935 push endpoint, or run it as an RFC 8936 poller.

What the ssf adapter does:

  • Receives SETs over push or poll, verifies the transmitter's signature and issuer according to the stream configuration, and rejects anything that fails verification (fail closed: an unverifiable event is dropped and logged, never acted on).
  • Resolves the subject. CAEP subject identifiers (email, issuer-and-subject, opaque) are mapped to the application's user id through an adapter option, because PermDock does not have a user store.
  • Invalidates. Each handler receives the resolved subject and the raw event. The typical action is updateTag(snapshotTag(user)) in Next.js, or calling the client invalidate() channel so usePermDock() refetches. Snapshots scoped with include share the same tag, so one event clears all of a user's scoped snapshots.
  • Feeds audit. Events are emitted on the same observability path as decisions (see audit and observability) so a session-revoked followed by a denied decision is traceable.

PermDock is only a receiver. It never transmits events and does not manage SSF streams beyond the configuration needed to verify incoming SETs. Invalidation is strictly per subject; there is no handler that invalidates everything for an issuer.

Outside Next.js, the revocations option carries the signal to clients: the receiver publishes session-revoked or changed on a RevocationFeed, and open connections end or revalidate (streams). An app without long-lived connections relies on the client's next revalidation.

Event lifecycle

  1. The IdP ends a user's session (admin action, risk signal, sign-out everywhere) and emits a session-revoked SET on the stream the application subscribed to.
  2. The SET reaches receiver, by POST (RFC 8935) or by the receiver's next poll (RFC 8936).
  3. receiver verifies the signature and issuer against the stream configuration. On failure the event is logged and dropped.
  4. The subject identifier is resolved to the application's user id through the configured mapper.
  5. The onEvent['session-revoked'] handler runs: updateTag(snapshotTag(user)) in Next.js, or the client invalidate() channel elsewhere.
  6. The next request for that user builds a fresh PermDock and snapshot; usePermDock().status on connected clients moves to stale and then ready after refetch.
  7. The event is recorded on the audit path alongside decisions.

Relationship to snapshot TTLs

CAEP does not replace the TTL; it shortens the window. Snapshots still expire so that a missed event (a transmitter outage, a poll gap) cannot leave stale authority indefinitely, and the TTL becomes a backstop rather than the primary freshness mechanism.

Mapping table

SSF / CAEP conceptPermDock concept
Transmitter (Entra, Okta, Auth0, Keycloak)Configured issuer whose SETs receiver accepts
Receiverreceiver from createPermDock in permdock/ssf
RFC 8935 push deliveryreceiver mounted as an HTTP endpoint
RFC 8936 poll deliveryreceiver run as a poller with the stream's poll endpoint
SET (Security Event Token)Verified, then dispatched to onEvent by event type
session-revokedInvalidate the subject's snapshot and client caches
credential-changeInvalidate the subject's snapshot (roles may depend on MFA state)
assurance-level-changeInvalidate only. The next subjectFrom* carries the new acr / amr; assurance() on the grant decides. The event never raises a policy.
Subject identifier formatsAdapter option mapping to the application's principal.id
Unverifiable SETDropped and logged; no invalidation, no grant change

Sources

Last updated on

On this page