Continuous Access Evaluation for Auth0: Building a CAEP/SSF Reference Demo
OpenID's Shared Signals Framework (SSF) and its Continuous Access Evaluation Profile (CAEP) both went final in August 2025. The premise is straightforward: rather than trusting a session or token for its entire lifetime, an identity provider and a risk platform exchange signed events in near real time, so a customer's assurance level, session validity, and delegated access can all be revisited the moment risk changes instead of at the next login.
Auth0 supports neither side of this natively. Universal Logout is the closest shipped artefact, and it is scoped tightly - it only receives revocation signals from Okta Workforce Identity Cloud's Identity Threat Protection, it implements the IETF Global Token Revocation draft rather than general CAEP, and it never claims SSF or CAEP conformance anywhere in its own documentation. If you want continuous, standards-based risk evaluation against Auth0 today, you are building it yourself.
In this post I will detail the reference implementation I built - continuous-trust-demo - a fictitious customer portal and risk platform wired together over a real SSF integration, and what building it revealed about how thin the tooling is for a standard that only recently went final.
The components we will cover in this post are as follows.
- Why Auth0 needed a stand-in for both SSF roles
- Emitting signed authentication and token events outward
- Receiving and reacting to CAEP risk signals
- A shared Policy Enforcement Point instead of a heavier authorisation service
- CIBA as the backend-initiated step-up mechanism
- Combining Auth0's own risk signal with the continuous one
Why Auth0 Needed a Stand-In for Both SSF Roles
SSF defines two roles. A transmitter builds and signs events as Security Event Tokens (SET, RFC 8417) and pushes them to receivers; a receiver registers against a transmitter's Stream Management API, verifies incoming SETs against the transmitter's published JWKS, and reacts. A real deployment needs Auth0 playing both roles at once - transmitting its own auth and token events outward, and receiving CAEP risk signals from wherever risk decisions actually get made.
Auth0 does neither, so the demo has a small service, auth0-ssf-adapter, standing in for Auth0 on both sides of the wire. I went looking for a library to build it on rather than hand-rolling RFC 8417 from scratch, and found the tooling for this specification is thinner than I expected for something that just reached Final status. SGNL.ai's secevent-js handles SET construction, signing, parsing, and verification properly, wrapping jose underneath, and their set-transmitter-js does push delivery per RFC 8935. Neither, nor anything else I could find on npm or GitHub, implements the Stream Management API itself - the registration handshake, JWKS hosting, and subject add/remove semantics that make a transmitter genuinely discoverable and manageable by a receiver. SGNL.ai's own full reference implementation of that, caep.dev, is Go only. The JavaScript ecosystem gets you the cryptography and the transport; the actual protocol scaffolding around them, you build.
Emitting Signed Authentication and Token Events Outward
Every authentication outcome and token issuance needs to become a SET so a risk platform can see what is happening as it happens. Auth0 Actions is the mechanism, and between three separate triggers it covers every token-issuance path Auth0 exposes a hook for.
The obvious trigger is the post-login Action, and my first pass at this design assumed it only fired on interactive login, leaving a real gap around refresh-token exchange. That assumption was wrong. Here is why I caught it: Auth0's own documentation states plainly that the trigger executes "after a user logs in and when a Refresh Token is exchanged." The event object even gives you the field to distinguish the two cases, event.transaction.protocol, which reports oauth2-refresh-token on a refresh exchange. Combined with the separate credentials-exchange trigger for machine-to-machine grants and custom-token-exchange for RFC 8693 flows, every token-issuance path Auth0 exposes a hook for can emit its SET synchronously, in the same Action that issues the token.
Each Action stays deliberately free of cryptography. It reports what happened - the trigger type, client ID, subject, requested scope - to a small endpoint on the adapter, which holds the actual signing key and builds the SET. Keeping the Actions this thin matters for two reasons: dashboard-pasted code is awkward to review and version, and Auth0's Actions runtime is not where you want to be debugging JOSE signing.
One more tooling gap only showed up once signals were actually flowing end to end. RFC 8417 permits any URI scheme as an event type key, so this demo's custom token-issued event initially used a urn: identifier. secevent-js's own verifier disagreed: isValidEventUri() accepts only http: and https: schemes, rejecting urn: outright with "Invalid event URI format." The failure mode was the annoying part, not the fix - the SET built and signed without complaint on the sending side, and only failed on the receiver's verification step, so for a while it looked like signals simply were not arriving rather than being actively rejected. Switching to an https:// URI fixed it in one line; finding it took reading the library's compiled source rather than trusting the spec's own permissiveness.
Receiving and Reacting to CAEP Risk Signals
The other direction is where the standard actually earns its name. Argus, the mock risk platform, is a genuine SSF transmitter for three CAEP 1.0 event types - risk-level-change, assurance-level-change, and session-revoked - all real, spec-defined types, not something I invented for convenience. The adapter verifies each incoming SET against Argus's published JWKS before acting on anything in it, which is the whole point: a receiver that skips signature verification is not doing continuous access evaluation, it is just parsing untrusted JSON.
Reactions split into two tiers, and conflating them was my first design mistake. A risk-level-change or assurance-level-change event updates a live risk/assurance record for that customer - nothing about their session changes, but the next policy check sees the new state immediately. A session-revoked event is different in kind: it calls Auth0's Management API to delete the session outright, because a live refresh token should die, not be softly deprioritised. Treating every signal as "kill the session" would have been simpler to build and much worse to demonstrate - most of what continuous evaluation buys you is the soft case, reacting without forcing a customer to log back in.
A Shared Policy Enforcement Point Instead of a Heavier Authorisation Service
The demo also needed to answer whether a delegate acting on a customer's behalf should keep working once risk changes, which is a fine-grained authorisation problem on paper. My first instinct was to reach for Auth0 FGA, whose Conditions genuinely do let you evaluate a CEL expression against arbitrary context - including a risk score - at check time. It is a legitimate mechanism, but pulling in an entire external authorisation service to answer one question was more than this needed.
What replaced it is a single Firestore-backed Policy Enforcement Point (PEP) - the same store the CAEP receiver already writes risk and assurance state into. One function answers "is this action allowed for this customer right now," reading live against that store on every call, and both the delegated-access check and the portal's sensitive-action checks call it. No separate store to provision, and it reframes the session-revocation mechanism from the previous section as one tier of reaction among several rather than the only lever available - which turned out to be a better teaching example than the FGA version, not just a simpler one.
CIBA as the Backend-Initiated Step-Up
The last piece is what happens when the PEP decides an action needs a higher assurance level than the customer currently has. The obvious answer - redirect the browser back to Auth0 with an elevated acr_values request - only works if the customer is sitting in an active browser session at that moment, which will not be true when the trigger is a risk signal arriving asynchronously in the background.
Client-Initiated Backchannel Authentication (CIBA) fits this better. The backend calls bc-authorize directly, and Auth0 pushes an approval prompt to the customer's enrolled device with no browser round-trip involved at all - the customer needs Guardian push enrolled beforehand for this to have anywhere to land. CIBA moves who initiates the step-up from the customer's browser to your backend; it does not remove the customer's approval from the loop, and it should not. The demo reuses this exact pattern from a contact-centre identity-verification demo I built earlier in the same project, which is a reasonable sign that CIBA is the right general-purpose answer for "start an authentication ceremony without a browser," not just a fit for one use case.
bc-authorize also accepts authorization_details per Rich Authorization Requests (RFC 9396), and this demo's CIBA resource server registers a custom RAR type for exactly that reason - so the approval prompt could, in principle, carry structured detail about what is being approved rather than a plain binding_message string. In practice, Auth0's standard consumer Guardian app rejects any authorization_details shape it does not recognise, with "authorization_details does not match the required schema for use with the Auth0 Guardian App." RAR-aware push prompts need a custom mobile app built against Auth0's Guardian SDK; the generic app almost every tenant actually has installed cannot render one. The registered RAR type still documents this API's authorization model - it just does not travel through this particular call for this demo's channel, and dropping it from the bc-authorize request was the actual fix.
Combining Auth0's Own Risk Signal with the Continuous One
Everything above assumes a customer is already mid-session and something changes underneath them. There is also the moment of login itself, and Auth0 already has an opinion about that one: Adaptive MFA / risk assessment, surfaced on the post-login event object as event.authentication.riskAssessment, which checks things like device familiarity, IP reputation, and travel pattern between logins and reports a confidence for each, plus an overall confidence.
Combining that native signal with the continuous risk level Argus has already established for the customer - from the CAEP signals described above, sitting in the same shared PEP state - into one login-time decision felt like the obvious extension. Two independent signals, one decision, at the one moment Auth0 itself already has a native opinion worth listening to. A third input joins them: which AMR values the session has already satisfied, from event.authentication.methods.
The policy itself has four branches. High risk - either Auth0's own assessment or Argus's level - always forces a second factor, no exceptions. Medium risk forces a second factor too, unless a passkey was already used as the first factor, in which case that is trusted as sufficient on its own; passkeys are phishing-resistant, but that trust does not extend to overriding a genuinely high-risk signal. Low risk requires nothing extra. And if MFA has already been satisfied this session - checked via AMR values already present in event.authentication.methods, not just this specific login - that resets the customer's risk to LOW outright, on the reasoning that completing a second factor is itself a risk-reducing signal, not just a box to tick.
Getting the semantics of that first signal backwards was the second design mistake worth naming here (the first was conflating soft and hard CAEP reactions, above). Adaptive MFA's confidence field reads, on a first pass, like a risk score - high confidence sounds like it should mean high risk. It means the opposite: it is confidence in a safe assessment. Auth0's own example payloads make this unambiguous once you look closely - a NewDevice check reporting confidence "high" alongside code "match" means high confidence the device is recognised, which is a safe finding, not a risky one. I built the entire policy around the inverted assumption before checking a real payload against the actual codes it returns, which is the same lesson as the urn: scheme mistake above in a different shape: the field name is not the specification, and a vendor's real example payload is worth more than either.
That native risk assessment also now travels through the same signed channel as everything else, not just informing a local decision. The token-issued SET described earlier carries the full riskAssessment object - not just the confidence label - so a receiver sees the entire per-check breakdown (UntrustedIP, NewDevice, ImpossibleTravel, and whatever else Auth0 evaluated) on the actual wire payload. It is a small addition, but it is the clearest illustration in this whole build of the point made at the very top: SSF standardises the transport and the signing, not the payload. What rides inside a SET is entirely up to the systems exchanging it.
Final Thoughts
The specs are mature - CAEP 1.0 and SSF 1.0 both finalised in August 2025 with the OpenID Foundation naming Apple, IBM, and Okta among the adopters - but the tooling around them is still early, and Auth0 specifically has no native role in any of it today. Building a reference implementation from secevent-js and set-transmitter-js upward was the practical path, and probably the only one available if you need this working against Auth0 in the near term rather than waiting for a platform to ship it.
Auth0 does have one native, non-CAEP threat-signal capability that pairs well with this build - Prioritized Log Streams. It is a single boolean, isPriority: true, on an otherwise ordinary Log Stream, and it guarantees delivery of eight fixed security events - breached-password use on login, signup, or reset, IP and account throttling, and MFA SMS delivery - even when a tenant is under the kind of load an actual attack produces, which is exactly when ordinary log delivery tends to backlog or drop. I wired the adapter to receive one of these and logged it distinctly from the CAEP SETs, since this is Auth0's own native detection rather than a risk signal Argus decided on, and the two should never look the same in an event log. It is a narrow, fixed allowlist rather than a general-purpose event router, but the fit with the rest of this demo is exact: continuous evaluation is only as good as the guarantee that a signal actually arrives, and this is Auth0 making that same guarantee for its own detection.
One last note, unrelated to CAEP or SSF but worth remembering regardless: deploying this reference implementation surfaced that Vercel's <project-name>.vercel.app domains are global across every Vercel account, not scoped per team. An unrelated, pre-existing project already owned the exact name this demo's customer portal wanted, so it silently landed on a different domain instead - and a shallow check (the response came back 200, the HTML <head> looked like any other Next.js app) missed the mismatch for longer than it should have. A 200 response is not the same claim as "this is serving what I think it's serving."
If you are looking at CAEP or SSF for your own Auth0 tenant, the OpenID Foundation's Shared Signals working group page is the place to track who else is contributing, and Auth0's Universal Logout documentation is worth reading closely for exactly how far the platform's own revocation story currently reaches.