Syed Hasibur Rahman28 min read
The Logout That Didn't: Session Revocation, Shared Signals, and the Half-Life of a Stolen Token
Every system can issue a session. Almost none can reliably end one. A password reset revokes a refresh token, but the fifteen-minute JWT in the attacker's terminal keeps working, and the six downstream SaaS applications that federated off that login never hear about it at all. The OpenID Foundation's Shared Signals Framework, together with CAEP and RISC, turns revocation from a local database write into a signal that propagates — built on the IETF's Security Event Token stack. This post unpacks the specs field by field, shows a working transmitter and receiver, and is honest about the parts that are still hard.
The incident starts the way they always do: politely. A customer emails at 01:40 to say she thinks someone else has been in her account. You check the audit log, and she is right — there is a login from an ASN she has never touched, and it is four hours old. You do the correct things, fast. Force a password reset. Revoke her refresh tokens. Kill the rows in the sessions table. By 01:52 you write back that the account is secure.
It is not secure at 01:52. It is secure at roughly 02:04, because the access token the attacker is holding is a signed JWT with a fifteen-minute lifetime and your resource servers validate it by checking a signature, not by asking anyone’s permission. For those twelve minutes your API will cheerfully serve every request the thief makes, and no line of code you can run changes that — the token is a statement your authorization server already made, notarized, in someone else’s pocket.
That is the good news, because it is bounded. The bad news is the part that never appears in your logs. Four hours earlier, the attacker used that session to sign in to the three SaaS tools your company federates through the same identity provider — the wiki, the ticketing system, the analytics dashboard. Each minted its own application session on the back of a successful OIDC authentication, with its own cookie, its own lifetime, its own idea of when to ask again. None of them is watching your sessions table. Your revocation was a local write to a local database, and by default they will not find out for hours or days.
This is the revocation gap, and it is the widest hole left in federated identity. Issuing access is a solved problem with a decade of excellent specifications behind it. Ending access — everywhere, promptly, provably — is where the stack has been quietly incomplete, and where, on 2 September 2025, three OpenID Foundation specifications were approved as Final and gave us something real to build on1.
This post walks that problem and that answer from the ground up: what “log out” actually means in a federated system, why the stateless-token bargain made revocation structurally hard, then the Shared Signals stack from the bottom — the Security Event Token, the subject identifier formats, the two delivery profiles, the stream framework, and finally CAEP and RISC, the vocabularies that say what happened. There is working code, and a long section on the parts that are genuinely still hard, because a post that pretends this is solved would be lying to you.
Part 1: “Log out” is three different operations wearing a trench coat
When a user clicks Sign out, at least three independent pieces of state are in play, and the button usually touches one.
The IdP session is a cookie at login.example.com saying this browser has authenticated as this human. It is what makes SSO feel like magic: the second application you visit does not prompt, because the IdP recognizes the cookie and returns an assertion.
The RP session is each application’s own session, created after it validated that assertion — a separate cookie at a separate origin with a separate lifetime, which the IdP typically does not control and often does not know exists. This is the one that governs the user’s actual experience.
The API credential is the OAuth access token the application uses to call APIs, plus the refresh token behind it. These may be opaque strings backed by a database row, or self-contained JWTs in the RFC 9068 profile2 — and that choice determines nearly everything about how revocable they are.
A sign-out that clears the RP session clears one of three; the IdP cookie survives, so clicking Sign in silently re-authenticates and the user concludes logout is broken. A sign-out through the IdP’s RP-initiated logout endpoint clears one and two for that application and leaves the five other tabs untouched. None of it touches the access token, which is the one an attacker actually wants.
OpenID Connect grew mechanisms for the first two. Back-Channel Logout 1.0, finalized in December 2023 by Mike Jones and John Bradley, lets the OP notify every RP directly, server to server, with no browser involvement3. The OP POSTs a Logout Token — a JWT carrying iss, aud, iat, exp, jti, and an events member containing http://schemas.openid.net/event/backchannel-logout — to each RP’s registered logout endpoint. Per the spec, “a Logout Token MUST contain either a sub or a sid Claim, and MAY contain both.”
It also contains a small piece of defensive spec design worth stealing: “A nonce Claim MUST NOT be present. Its use is prohibited to make a Logout Token syntactically invalid if used in a forged Authentication Response in place of an ID Token.”3 That is a spec author deliberately engineering a deficiency into a token so it cannot be replayed into a neighbouring protocol slot.
But back-channel logout has two limits that matter enormously here. The first is structural, and the spec admits it: the RP returns HTTP 200 on success and 400 on failure, but “if the identified End-User is already logged out at the RP when the logout request is received, the logout is considered to have succeeded,” and “the mechanism by which the RP achieves this is implementation specific”3. The OP learns that a well-formed request was accepted. It does not learn that access actually ended.
The second limit is the one this whole post is about: back-channel logout only fires on logout. It has exactly one event. It has nothing to say when the user’s password changes, when their laptop falls out of MDM compliance, when their risk score spikes, when their second factor is deleted, or when a fraud system decides at 03:00 that the account is compromised. Those are all moments where downstream applications should re-evaluate access, and the logout channel is structurally incapable of carrying them.
Part 2: The stateless bargain, and what we sold to get it
Why can’t we just check? Because we deliberately chose not to.
OAuth 2.0 leaves access-token format undefined. For years everyone invented their own; RFC 9068, published in October 2021 by Vittorio Bertocci, standardized the obvious one — a signed JWT with iss, exp, aud, sub, client_id, iat, jti, and a scope claim2. The appeal is entirely legitimate: a resource server validates the token against a cached JWKS. No network call, no shared database, no dependency on the authorization server being up. You can scale resource servers to a hundred regions and the authorization server never feels it.
The alternative is RFC 7662 token introspection, published October 2015 with Justin Richer as editor: the resource server POSTs the token to the authorization server and gets back {"active": true, ...}4. That is perfect revocation — the answer computed fresh, every time, by the party that actually knows. It is also a synchronous dependency on your authorization server for every API call in your fleet, which is why almost nobody runs it at scale without a cache, and a cache is a JWT with extra steps and worse failure modes.
So the industry took the bargain, trading revocation latency for availability and scale. That was the right trade. But the bill is a specific, quantifiable number: the time between deciding to revoke and the last outstanding token expiring. Call it the half-life of a stolen token. With fifteen-minute access tokens it is up to fifteen minutes; with the one-hour tokens many teams actually ship, because fifteen minutes generated too many refresh calls, it is up to an hour. An hour is a very long time in an active compromise.
Part 3: What RFC 7009 actually promises, read carefully
The OAuth answer to revocation is RFC 7009, “OAuth 2.0 Token Revocation,” published in August 2013 with Torsten Lodderstedt as editor alongside Stefanie Dronia and Marius Scurtescu5. It defines a /revoke endpoint. Most people assume it means what the name says.
Read the normative language precisely. Implementations MUST support revoking refresh tokens. They SHOULD support revoking access tokens. And when a refresh token is revoked, the authorization server SHOULD also invalidate all access tokens issued under the same grant — but only “if the authorization server supports the revocation of access tokens.”
The spec then states the consequence plainly: “If the authorization server does not support access token revocation, access tokens will not be immediately invalidated when the corresponding refresh token is revoked.”5
That is not a loophole; it is an honest acknowledgment that for self-contained tokens there is no mechanism to invalidate. You cannot un-sign a signature. The authorization server can stop issuing new ones — which is exactly what revoking the refresh token achieves — but the ones already in the wild are facts about the past, and they remain true until exp.
So the realistic model is: revocation stops renewal; expiry stops use. Everything else is an attempt to shorten the gap between those two, and there are only three ways to do it. Make tokens shorter (costs throughput, and only shrinks the window — never closes it). Introspect every call (costs availability). Or push the revocation decision outward to everyone who needs it, so they can act on it locally.
That third option is Shared Signals.
Part 4: The Security Event Token
The foundation is IETF, not OpenID, and it predates the framework by seven years. RFC 8417, “Security Event Token (SET),” was published in July 2018 by Phil Hunt, Mike Jones, William Denniss, and Morteza Ansari6. Its abstract is admirably narrow:
“A SET describes statements of fact from the perspective of an issuer about a subject. These statements of fact represent an event that occurred directly to or about a security subject, for example, a statement about the issuance or revocation of a token on behalf of a subject.”6
A SET is a JWT, which means it is signed (JWS), optionally encrypted (JWE), and verifiable with machinery every identity system already has. The claims that matter:
| Claim | Status | Meaning |
|---|---|---|
iss | REQUIRED | Who is asserting this happened |
iat | REQUIRED | When the SET was issued |
jti | REQUIRED | Unique identifier for this SET |
events | REQUIRED | Map of event-type URI → event payload |
aud | RECOMMENDED | Intended recipients |
sub | OPTIONAL | The principal the event is about |
toe | OPTIONAL | When the event actually occurred |
txn | OPTIONAL | Correlation identifier across related SETs |
Two of those deserve attention. iat and toe are different, on purpose: the event happened at toe, the notification was minted at iat. In a healthy system they are seconds apart; with a backed-up queue, a paused stream, or a batching transmitter they are not, and a receiver that ignores toe will make wrong decisions about ordering. Store both. And events is a map, not a string — the key is an event-type URI, the value an object carrying that event’s claims.
The security considerations are where RFC 8417 earns its keep. A SET is a JWT about a subject signed by an identity provider, which is to say it is shaped exactly like an ID Token. The spec names the hazard: “A SET might be confused with an ID Token if a SET is mistakenly or maliciously used in a context requiring an ID Token.” The mitigation is structural — profiles are told to leave exp out, since ID Tokens require it, so a SET presented as an ID Token fails validation on a missing mandatory claim6. There is also a dedicated media type and typ header value, application/secevent+jwt, which a careful validator checks explicitly.
The rule underneath all of it: a SET is a notification, never a credential. It says something happened. It authorizes nothing, and a receiver that treats an incoming SET as permission to act has built a remote-control vulnerability with a standards-track wrapper.
Part 5: Naming the subject without leaking the subject
A revocation notice is useless if the receiver cannot work out who it is about, and that is hard across an organizational boundary: your IdP knows the user as an internal UUID, the SaaS vendor by work email, the analytics tool by a hash — and nobody wants to standardize on the most privacy-corrosive identifier just because it is the most universal.
RFC 9493, “Subject Identifiers for Security Event Tokens,” published December 2023 by Annabelle Backman, Marius Scurtescu, and Pamela Jain, solves this by making the identifier self-describing7. Every subject identifier is a JSON object with a format member naming its type, plus that format’s required members. Eight formats are defined:
account— anacct:URIemail— an email addressiss_sub— an issuer/subject pair, the federated-identity native formopaque— a bareidstring meaningful only to the two partiesphone_number— E.164did— a Decentralized Identifier URLuri— a generic URIaliases— an array of the above, for when one party knows a user by several names
{
"format": "iss_sub",
"iss": "https://login.example.com/",
"sub": "a1b2c3d4-0000-4fff-8000-9e6a7f2b1c33"
}
Reach for iss_sub between systems that already federate — it is exactly the pair the receiver stored when the user first signed in, so no correlation work and no PII on the wire. Reach for opaque when you want a stable shared handle and nothing more, and aliases for the messy real case where you must send both an email and a directory ID because you do not know which the receiver indexed on.
One real-world wart worth knowing: Google’s production RISC implementation predates the RFC and uses the member name subject_type where the standard says format. Google’s documentation now marks subject_type deprecated and says new services MUST NOT use it8, but the deployed corpus is what it is, and a receiver talking to Google today handles both spellings.
Part 6: Getting the event from here to there
Two companion RFCs, both published November 2020, both edited by Annabelle Backman and Mike Jones with Marius Scurtescu, Morteza Ansari, and Anthony Nadalin9.
RFC 8935 — push. The transmitter POSTs a single SET to the receiver’s endpoint with Content-Type: application/secevent+jwt and Accept: application/json. On success the receiver returns 202 Accepted with an empty body. On failure it returns 400 with a JSON object containing exactly two members:
{
"err": "invalid_key",
"description": "Key ID 12345 has been revoked."
}
The defined error codes are invalid_request, invalid_key, invalid_issuer, invalid_audience, authentication_failed, and access_denied, with room for IANA registration9.
Note the 202, not 200. The receiver acknowledges receipt, not completion — it has taken custody of the event, and what it does about it is asynchronous and its own business. That is exactly the honest thing back-channel logout’s 200 was not.
On retransmission the spec is deliberately conservative: a transmitter “should not retransmit a SET unless the SET Transmitter suspects that previous transmissions may have failed due to potentially recoverable errors,” and otherwise SHOULD NOT9. Combined with jti, that pushes receivers toward at-least-once delivery with idempotent handling — which you must actually implement rather than assume.
RFC 8936 — poll. The inverse: the receiver POSTs a request to the transmitter, gets back a batch of SETs, and acknowledges the ones it has durably processed10. This is the profile for receivers behind a firewall with no public ingress, which describes most enterprise deployments and essentially every internal service. Poll is not the lesser option — for many topologies it is the only workable one, and its explicit acknowledgment step gives stronger delivery semantics than push.
Part 7: The Shared Signals Framework — who talks to whom, and how that gets set up
SETs and their delivery profiles give you an envelope and a postal service. What was missing until last year was the part that makes it operable between two organizations that have never met: how a receiver subscribes, which events it wants, which users it cares about, and how anyone knows the stream is still alive.
That is the OpenID Shared Signals Framework Specification 1.0, dated 29 August 2025 and approved Final on 2 September 2025, authored by Atul Tulshibagwale, Tim Cappalli, Marius Scurtescu, Annabelle Backman, John Bradley, and Shayne Miel11. The model is two roles — Transmitter and Receiver — connected by a managed Event Stream.
Discovery. A transmitter publishes metadata at /.well-known/ssf-configuration: issuer, jwks_uri, delivery_methods_supported (the URNs urn:ietf:rfc:8935 and urn:ietf:rfc:8936), the management endpoint URLs, authorization_schemes, critical_subject_members, and default_subjects — whether a new stream starts subscribed to ALL subjects or NONE11.
Stream configuration. A REST resource the receiver creates, reads, updates and deletes, carrying stream_id, iss, aud, delivery details, min_verification_interval, and — the important triple — events_supported (what the transmitter can send), events_requested (what the receiver asked for), and events_delivered (what the transmitter actually agreed to send)11. Those three being distinct is good design: the receiver states a wish, the transmitter states a commitment, and the gap is visible rather than silently discarded. A receiver that never diffs events_requested against events_delivered will one day discover it has been confidently not receiving the event it cared most about.
Subjects. Add-subject and remove-subject endpoints let a receiver scope a stream to the users it actually serves, rather than firehosing every event about every account at the IdP11. For a SaaS vendor with ten thousand tenants this is the difference between a viable integration and a denial of service.
Status. A stream is enabled, paused, or disabled, and the semantics are where data loss lives: enabled means events flow; paused means the transmitter holds events and may drop some when the stream resumes; disabled means it neither sends nor holds11. “Paused” is not “buffered indefinitely.” If your receiver is down long enough for the transmitter to start shedding, you have a gap and the framework has no replay mechanism to fix it. Your recovery story is a reconciliation job against the IdP, and you should write it before you need it.
Verification. The framework defines a verification event type, https://schemas.openid.net/secevent/ssf/event-type/verification, carrying an optional opaque state and addressed to the stream itself11. This answers the most insidious failure mode in any push-based security pipeline: a stream that silently died looks exactly like a stream where nothing bad has happened. Zero events is the expected steady state, so without a heartbeat you cannot distinguish “quiet” from “broken” — and you will find out which during an incident.
Part 8: CAEP and RISC — the vocabulary
The framework moves events. The profiles define what the events mean. Two were finalized alongside it, splitting the problem cleanly.
CAEP — the Continuous Access Evaluation Profile 1.0, dated 29 August 2025, by Tim Cappalli and Atul Tulshibagwale, covers session and device posture12. From its abstract: event types “intended to be used between cooperating Transmitters and Receivers such that Transmitters may send continuous updates using which Receivers can attenuate access to shared human or robotic users, devices, sessions and applications.”
Eight event types, all under https://schemas.openid.net/secevent/caep/event-type/:
| Event | Required claims | What it is for |
|---|---|---|
session-revoked | — | This session is over. Act now. |
token-claims-change | claims | A claim in the token is no longer accurate — role removed, group changed |
credential-change | credential_type, change_type | Password reset, cert renewed, FIDO2 key deleted |
assurance-level-change | namespace, current_level | The user’s AAL moved, in either direction |
device-compliance-change | previous_status, current_status | The endpoint left (or rejoined) MDM compliance |
session-established | — | A new session exists, with acr/amr/ext_id context |
session-presented | — | The session was presented somewhere |
risk-level-change | principal, current_level | A risk engine changed its mind |
Nearly all carry the same optional set: event_timestamp, initiating_entity, reason_admin, and reason_user. Those last two are a small masterpiece of practical spec design. reason_admin is the detailed, potentially sensitive explanation for the receiver’s operations and audit trail; reason_user is text safe to show the human who just got signed out. Anyone who has watched a user stare at a login screen wondering whether they were hacked or the system broke understands why a standard should carve out a field for “what we are willing to tell them.”
credential-change is the event that closes the hole we opened with. When the user resets her password at 01:47, that is a credential-change with credential_type: password and change_type: update, propagating to every downstream application in seconds — so the wiki, the ticketing system, and the analytics dashboard all learn to distrust sessions established before that moment. No shared database, no polling, no four-hour wait.
RISC — the OpenID RISC Profile 1.0, finalized the same day, covers the account lifecycle rather than the session: account-disabled, account-enabled, account-purged, identifier-changed, credential-compromise, sessions-revoked, and recovery events13. The division of labour is clean: CAEP says this session is no longer trustworthy; RISC says this account is no longer what you think it is.
RISC is also the profile with years of real production traffic behind it, because Google’s Cross-Account Protection is built on it. Google transmits sessions-revoked, tokens-revoked, token-revoked, account-disabled, account-enabled, account-credential-change-required, and verification; receivers register via stream:update and validate incoming SETs against the issuer and JWKS published at https://accounts.google.com/.well-known/risc-configuration, with aud matching one of their OAuth client IDs8. Google’s guidance on receiving sessions-revoked is one sentence and exactly right: “Re-secure the user’s account by ending their currently open sessions.”
If you operate anything with Sign in with Google, you can subscribe today. It is free, documented, and the highest-leverage security integration most small teams are not doing.
Part 9: A working receiver
Here is a push receiver in Python — Flask for the endpoint, PyJWT for validation. The shape is what matters: validate hard, acknowledge fast, act asynchronously.
import jwt
from jwt import PyJWKClient
from flask import Flask, request, jsonify
app = Flask(__name__)
TRANSMITTER_ISS = "https://login.example.com/"
OUR_AUDIENCE = "https://wiki.example.net/ssf"
jwks = PyJWKClient("https://login.example.com/jwks.json")
CAEP = "https://schemas.openid.net/secevent/caep/event-type/"
def err(code, description):
# RFC 8935 section 2.3: 400 with exactly {"err", "description"}
return jsonify(err=code, description=description), 400
@app.post("/ssf/events")
def receive_set():
if not request.content_type.startswith("application/secevent+jwt"):
return err("invalid_request", "Expected application/secevent+jwt.")
raw = request.get_data(as_text=True)
try:
key = jwks.get_signing_key_from_jwt(raw).key
except Exception:
return err("invalid_key", "Unknown or unusable signing key.")
try:
token = jwt.decode(
raw,
key,
algorithms=["RS256", "ES256"],
audience=OUR_AUDIENCE,
issuer=TRANSMITTER_ISS,
# A SET carries no exp; do not demand one.
options={"require": ["iss", "iat", "jti"], "verify_exp": False},
)
except jwt.InvalidAudienceError:
return err("invalid_audience", "SET is not addressed to this receiver.")
except jwt.InvalidIssuerError:
return err("invalid_issuer", "Unexpected issuer.")
except jwt.PyJWTError as e:
return err("invalid_request", f"SET failed validation: {e}")
events = token.get("events") or {}
if not events:
return err("invalid_request", "No events member.")
# Idempotency is the receiver's job: RFC 8935 delivery is at-least-once.
if seen_before(token["jti"]):
return "", 202
for event_type, payload in events.items():
enqueue_for_processing(event_type, payload, token)
record_jti(token["jti"])
return "", 202 # 202 Accepted, empty body. Not 200.
Three details are load-bearing and all three are commonly got wrong. Do not require exp — most JWT libraries verify expiry by default and some demand the claim, but a SET deliberately omits it, so forgetting verify_exp: False means rejecting every well-formed event from a conforming transmitter. Return 202 with an empty body, not 200 and not a JSON receipt; interoperability testing checks this. And deduplicate on jti before doing anything: a session-revoked applied twice is harmless, but a credential-change that triggers a user notification applied twice sends someone two alarming emails at 02:00.
Now the handler — the part that actually attenuates access:
def enqueue_for_processing(event_type, payload, token):
subject = payload.get("subject") or token.get("sub_id")
user = resolve_subject(subject) # handles iss_sub, email, opaque, aliases
if user is None:
return # not our user; drop quietly
# Prefer the event's own timestamp; fall back to SET issuance.
at = payload.get("event_timestamp") or token.get("toe") or token["iat"]
if event_type == CAEP + "session-revoked":
end_sessions(user, established_before=at)
elif event_type == CAEP + "credential-change":
# Any session that predates the credential change is suspect.
end_sessions(user, established_before=at)
require_fresh_authentication(user)
elif event_type == CAEP + "device-compliance-change":
if payload["current_status"] == "not-compliant":
downgrade_to_read_only(user)
elif event_type == CAEP + "assurance-level-change":
if payload.get("change_direction") == "decrease":
step_up_required(user, payload["current_level"])
And the transmitter side, for completeness — minting a session-revoked SET:
import time, uuid, jwt
def emit_session_revoked(private_key, kid, receiver_aud, user_iss, user_sub,
reason_user="Your password was changed."):
now = int(time.time())
claims = {
"iss": TRANSMITTER_ISS,
"aud": receiver_aud,
"iat": now,
"jti": str(uuid.uuid4()),
"toe": now,
"events": {
CAEP + "session-revoked": {
"subject": {"format": "iss_sub", "iss": user_iss, "sub": user_sub},
"event_timestamp": now,
"initiating_entity": "policy",
"reason_admin": {"en": "Credential reset after suspected ATO."},
"reason_user": {"en": reason_user},
}
},
}
return jwt.encode(claims, private_key, algorithm="ES256",
headers={"typ": "secevent+jwt", "kid": kid})
Note typ: secevent+jwt in the header and the total absence of exp. That combination is what makes this token structurally unusable as an ID Token, which is precisely the point RFC 8417 was making.
Part 10: The parts that are genuinely hard
Everything above is the demo. Here is what you hit in week three.
Revocation is a hint, not a command. The receiver is the authority over its own sessions and must stay that way. A SET arrives over a channel that can be delayed, duplicated, or — if your validation is sloppy — forged. A receiver that unconditionally executes whatever arrives has built a cross-organizational remote logout primitive, and the first person to find a signature-validation bug in it gets to deny service to your entire user base. Validate issuer, audience, signature and key; then decide for yourself what to do.
Ordering is not guaranteed, and timestamps do not rescue you. Nothing in push delivery guarantees a session-established arrives before the session-revoked that concerns it. If your handler applies the last message it received, an out-of-order pair re-enables a session you just killed. The defensive pattern is monotonic: never grant on an event, only attenuate, and decide relative to an established_before watermark rather than a boolean. Revoking sessions established before time T is idempotent and order-independent; setting session_valid = false then true is neither.
“Revoke the session” is ambiguous across a trust boundary. This browser session, all sessions for this user at this application, or all sessions everywhere? CAEP’s subject structure lets the transmitter be precise — including complex subjects, where revocation applies to any session matching the combined criteria12 — but the receiver still has to map that onto a session model designed by different people for different reasons. Get it wrong conservatively. An unnecessary re-login is an annoyance; a surviving attacker session is an incident.
Fan-out is a real cost. One IdP, five hundred relying parties, a bulk policy change affecting fifty thousand users: twenty-five million signed JWTs, each an HTTP request. Transmitters need batching, backpressure and per-stream rate limiting; receivers need to survive a burst without the queue becoming the outage. The subjects endpoint is the main lever — a receiver subscribed only to its own users cuts the problem by orders of magnitude.
reason_admin is a data-exfiltration surface pointed at yourself. It is free text going to a third party, and anything your risk engine knows — device fingerprints, geolocation, behavioural scoring, the name of the detection that fired — can end up in a vendor’s log retention. The CAEP Interoperability Profile, still a draft, requires reason_admin to be a non-empty object for the main event types14, so you cannot simply omit it in a conformant deployment. Decide deliberately what goes in it.
And the half-life problem does not fully go away. SSF propagates the decision in seconds. It does not shorten the life of an access token already minted, because nothing can. What it does is ensure that every party capable of refusing the next request knows to refuse it. That is a large improvement, and it is not the same thing as instant universal revocation.
Part 11: Where this honestly stands in September 2026
The specifications are done. SSF 1.0, CAEP 1.0 and RISC 1.0 were approved Final on 2 September 2025 by a vote of 85 in favour, 1 against and 25 abstaining, from 111 of the OpenID Foundation’s 433 voting members — and Final status, as the Foundation notes, “provides intellectual property protections to implementers of the specification and is not subject to further revision”1. Implementers were waiting for exactly that signal.
The implementations are uneven. Okta ships the most complete commercial implementation, with transmitter and receiver capabilities in production, and has been the loudest institutional advocate; interoperability demonstrations at the 2025 Gartner IAM events involved nine participants — SGNL, Google, IBM, Okta, Omnissa, Relock, SailPoint, Thales and Beyond Identity — which is a genuine ecosystem rather than one vendor’s marketing15. Google’s RISC transmitter has been live for years under the Cross-Account Protection brand and is the one most teams can adopt immediately8. Keycloak merged an SSF transmitter in May 2026 behind an experimental feature flag. Microsoft Entra’s story as an outbound transmitter to third parties remains thin in the public documentation.
The CAEP Interoperability Profile 1.0 — the document that pins down the minimum a conforming implementation must do — is still at Draft 0114. It requires at least one of session-revoked, credential-change, device-compliance-change and risk-level-change; mandates the email, iss_sub and (for verification) opaque subject formats; and requires push or poll delivery. Until it is final, “supports CAEP” on a datasheet is a claim to verify by testing, not by reading.
NIST’s direction of travel, meanwhile, is clear. SP 800-63B-4, published August 2025, tightened session handling: at AAL2 a definite overall reauthentication timeout SHALL be established and SHOULD be no more than 24 hours, with an inactivity timeout that SHOULD be no more than 1 hour; at AAL3 the overall timeout SHALL be no more than 12 hours with a 15-minute inactivity timeout16. Those are ceilings on how long a session may live in the absence of any signal — which tells you what the regulator thinks the blast radius of an unnoticed compromise should be, and makes the case for shared signals rather than weakening it.
The threat is not hypothetical. Between 28 September and 17 October 2023, an attacker inside Okta’s customer support case management system read HAR files uploaded by customers for troubleshooting — files that routinely contain live session cookies. 134 customers’ files were accessed, and the attacker used the harvested tokens to hijack the sessions of five of them, of whom 1Password, BeyondTrust and Cloudflare disclosed publicly17. Every one of those hijacks was a valid session presented by the wrong person. No credential was cracked and no MFA was bypassed, so no amount of stronger authentication at the front door would have changed the outcome — the front door was never used. The only defence that engages a stolen live session is continuous evaluation of that session after it exists.
Part 12: What to actually do
- If you accept Sign in with Google, subscribe to Cross-Account Protection this quarter. It is documented, free, and closes a real hole. Validate against the RISC discovery document, handle both
formatand the deprecatedsubject_type, and act onsessions-revokedandaccount-disabledby terminating local sessions. - Write down your half-life. Access-token lifetime plus refresh-token lifetime plus downstream RP session lifetime. That number is how long an attacker keeps access after you have done everything you know how to do. Most teams have never computed it and are startled by the answer.
- Emit before you consume, if you are an IdP or platform. A transmitter is easier than a receiver — you already know when sessions die. Start with
session-revokedandcredential-changeover RFC 8935 push, to your own first-party applications, before you go near a third party. - Make every handler monotonic and idempotent. Dedupe on
jti; act onestablished_beforewatermarks; never grant on an inbound event. This is the difference between a system that degrades safely and one that an out-of-order pair of messages can be used to attack. - Alarm on stream silence. Verification events, on a schedule, with a pager attached. A dead stream is invisible by construction.
- Ask vendors the specific question. Not “do you support CAEP” but “which event types do you transmit, which do you receive, which subject identifier formats, push or poll, and can I see
events_deliveredon a live stream.” The gap between the first answer and the second is where procurement disappointment lives. - Keep shortening token lifetimes anyway. Shared signals reduce the number of parties who keep serving a compromised session. Short expiry still reduces the window for the one token already in flight. These are complementary defences, and neither replaces the other — the same relationship DPoP has with token hygiene.
Closing
The identity industry spent fifteen years perfecting the moment of authentication. We built better factors, then unphishable ones; we hardened the token endpoint, sender-constrained the tokens, and profiled the JWT. All of that was necessary, and all of it addresses a single instant — the one where a session begins.
Almost nothing addressed the far longer interval that follows, in which the session simply continues to be true because no mechanism existed to make it false. We had a /revoke endpoint that, read honestly, promises to stop issuing rather than to stop working. We had a logout notification with one event type and no confirmation. And we had a comfortable industry habit of calling a row deleted from a local sessions table “revocation,” when six other systems were still serving the same human on the strength of an assertion we made hours ago and had no way to take back.
Shared Signals does not make revocation instant, and the specs are careful never to claim it does. What it does is more modest and more useful: it turns a local decision into a propagating fact, over a transport that is signed, profiled, discoverable, and now Final. Your revocation stops being a private opinion and becomes something the rest of the federation can hear.
The number to hold onto is the one from the top of this post — the twelve minutes, and the four hours behind them. The twelve minutes are physics; a signed token is a statement about the past and you cannot unsay it. The four hours were never physics. They were a missing standard, and as of September 2025 they are a missing integration. That one is on us.
References
OpenID Foundation, “Three Shared Signals Final Specifications Approved”, 2 September 2025. https://openid.net/three-shared-signals-final-specifications-approved/ ↩︎ ↩︎
Bertocci, V., “JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens”, IETF RFC 9068, October 2021. https://www.rfc-editor.org/rfc/rfc9068.html ↩︎ ↩︎
Jones, M. and Bradley, J., “OpenID Connect Back-Channel Logout 1.0 incorporating errata set 1”, OpenID Foundation, Final, 15 December 2023. https://openid.net/specs/openid-connect-backchannel-1_0.html ↩︎ ↩︎ ↩︎
Richer, J. (Ed.), “OAuth 2.0 Token Introspection”, IETF RFC 7662, October 2015. https://www.rfc-editor.org/rfc/rfc7662.html ↩︎
Lodderstedt, T. (Ed.), Dronia, S., and Scurtescu, M., “OAuth 2.0 Token Revocation”, IETF RFC 7009, August 2013. https://www.rfc-editor.org/rfc/rfc7009.html ↩︎ ↩︎
Hunt, P., Jones, M., Denniss, W., and Ansari, M., “Security Event Token (SET)”, IETF RFC 8417, July 2018. https://www.rfc-editor.org/rfc/rfc8417.html ↩︎ ↩︎ ↩︎
Backman, A. (Ed.), Scurtescu, M., and Jain, P., “Subject Identifiers for Security Event Tokens”, IETF RFC 9493, December 2023. https://www.rfc-editor.org/rfc/rfc9493.html ↩︎
Google for Developers, “Protect user accounts with Cross-Account Protection”. https://developers.google.com/identity/protocols/risc ↩︎ ↩︎ ↩︎
Backman, A. (Ed.), Jones, M. (Ed.), Scurtescu, M., Ansari, M., and Nadalin, A., “Push-Based Security Event Token (SET) Delivery Using HTTP”, IETF RFC 8935, November 2020. https://www.rfc-editor.org/rfc/rfc8935.html ↩︎ ↩︎ ↩︎
Backman, A. (Ed.), Jones, M. (Ed.), Scurtescu, M., Ansari, M., and Nadalin, A., “Poll-Based Security Event Token (SET) Delivery Using HTTP”, IETF RFC 8936, November 2020. https://www.rfc-editor.org/rfc/rfc8936.html ↩︎
Tulshibagwale, A., Cappalli, T., Scurtescu, M., Backman, A., Bradley, J., and Miel, S., “OpenID Shared Signals Framework Specification 1.0”, OpenID Foundation, Final, 29 August 2025. https://openid.net/specs/openid-sharedsignals-framework-1_0-final.html ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎
Cappalli, T. and Tulshibagwale, A., “OpenID Continuous Access Evaluation Profile 1.0”, OpenID Foundation, Final, 29 August 2025. https://openid.net/specs/openid-caep-1_0-final.html ↩︎ ↩︎
“OpenID RISC Profile Specification 1.0”, OpenID Foundation, Final, 2025. https://openid.net/specs/openid-risc-1_0-final.html ↩︎
Tulshibagwale, A., Deshpande, A., and Schreiber, J., “CAEP Interoperability Profile 1.0 — Draft 01”, OpenID Shared Signals Working Group. https://github.com/openid/sharedsignals/blob/main/openid-caep-interoperability-profile-1_0.md ↩︎ ↩︎
Okta, “Okta leads the way: Driving real-time security with Shared Signals”. https://www.okta.com/blog/product-innovation/okta-leads-the-way-driving-real-time-security-with-shared-signals/ ↩︎
National Institute of Standards and Technology, “NIST Special Publication 800-63B-4: Digital Identity Guidelines — Authentication and Authenticator Management”, August 2025. https://pages.nist.gov/800-63-4/sp800-63b.html ↩︎
Okta Security, “Unauthorized Access to Okta’s Support Case Management System: Root Cause and Remediation”, November 2023. https://sec.okta.com/articles/2023/11/unauthorized-access-oktas-support-case-management-system-root-cause/ ↩︎