Skip to content
FoxAuth

Blog

Okta Universal Logout is an expired draft

Okta Universal Logout follows a draft standard that expired before anyone adopted it. What Okta sends, the nbf timing trap, and checks no setting can relax.

By FoxAuth ·

The requirement sounded simple. A company’s employees sign in to our authorization server through the company’s identity provider (IdP): the service that holds the employees’ accounts and where they sign in. Sometimes the company’s security team decides that someone’s access has to end, everywhere and right now. Then the identity provider should be able to tell us. Not at the person’s next sign-in, which might be tomorrow. Now: their sessions, their refresh tokens, all of it.

So I went looking for the standard way an identity provider tells an application “end everything this person has”. Such an application is called a relying party: it relies on the provider to check who is signing in. There is no standard way. There is a final specification, but for a narrower job. There is another one for a broader job, but the big providers do not send its messages to other companies’ applications. And there is an individual Internet-Draft. An Internet-Draft is a working document on its way to becoming a standard. An individual one is written by its author alone, not by a working group. This one was never adopted, and it has expired. It is the one Okta sends.

Of the big corporate identity providers, only Okta sends “end everything”, and it follows an expired draft

Here is what each provider will actually send to an application like ours, as of October 2026. It is less than the marketing pages suggest.

What six identity providers send to a third-party relying party when a user’s access should end: Okta sends global token revocation, Keycloak, Auth0 and Ping send OpenID Connect back-channel logout, Google sends RISC for consumer accounts only, and Microsoft Entra ID sends nothingProviderSends to a third-party applicationEndsOktaglobal token revocation (expired draft)everything the user holdsKeycloak, Auth0, Pingback-channel logout (final spec)sessions from one sign-inGoogleRISC eventsconsumer accounts onlyMicrosoft Entra IDnothingcontinuous access evaluationis Microsoft’s ownThe one row that ends everything rests on a document no working group adopted.Okta sends it under its Identity Threat Protection licence, as Universal Logout.

Back-channel logout is a final OpenID specification, and a good one. With it, the provider tells the application about a sign-out directly, server to server, without going through the browser. But it reports a sign-out, not a compromise. It ends only the sessions that came from one sign-in at the provider (one upstream session). And offline access, which lets an application keep working while the user is away, is meant to survive such a sign-out.

Shared Signals and CAEP are the broad standard for “this session is revoked”. But none of the big corporate providers sends those signals to just any relying party. Entra sends an outside application nothing at all.

That leaves Okta’s Universal Logout. The draft behind it is draft-parecki-oauth-global-token-revocation. Revision 06 is from February 2026. It expired on 28 August, and the OAuth working group never adopted it. There is no draft-ietf- successor either (that prefix marks a draft a working group has adopted). We looked at sixteen comparable products. Exactly one other of them, Auth0, accepts this request.

I built it anyway, and I think that was right. But only with three safeguards, and I would now insist on them for anything built on an expired draft. First, we build to the request Okta documents. We do not read the draft as though it were a standard. Second, everything sits behind its own switch, globalTokenRevocation.enabled. It is off by default, and its description in the console says the draft has expired. Third, the wire format (exactly what arrives over the network, and in what shape) lives in one small module. Under that module sits a core that knows nothing about the format. If the format changes, or dies, the part that decides who may end whose access stays as it is.

What Okta sends, whom it may name, and what we end

Okta sends a POST request. Its JSON body names the user. Its Authorization header carries a JWT that Okta signs with its own keys:

POST /acme/global-token-revocation HTTP/1.1
Host: auth.example.com
Authorization: Bearer eyJ0eXAiOiJnbG9iYWwtdG9rZW4tcmV2b2NhdGlvbitqd3QiLCJhbGciOiJSUzI1NiJ9...
Content-Type: application/json
{ "sub_id": { "format": "iss_sub", "iss": "https://acme.okta.com", "sub": "00u1a2b3c4d5e6f7g8h9" } }

Decode that JWT and you see who is asking and for which endpoint. From here on I call it the assertion: a signed statement that one party makes to another.

{ "typ": "global-token-revocation+jwt", "alg": "RS256", "kid": "q8J1..." }
{
"iss": "https://acme.okta.com",
"sub": "0oa1b2c3d4e5f6g7h8i9",
"aud": "https://auth.example.com/acme/global-token-revocation",
"iat": 1759917600,
"nbf": 1759917300,
"exp": 1759917900,
"jti": "1a8c3e0f-5b2d-4c7e-9f61-2d0b8a4e7c35"
}

iss is the Okta organisation. sub is our client id in that organisation. Okta documents it as client_id/appInstanceId; only a real run will show which form actually arrives. Together, these two fields point to one upstream provider (an outside identity provider our users sign in through) configured on one bucket. A bucket is this server’s name for a set of end users with their own sign-in rules. Once a bucket has its own address, it also issues tokens in its own name, that is, it becomes its own issuer.

The body’s sub_id names the person: by their subject (their id at that provider) or, in Okta’s other format, by email. Not just anyone can be named. The provider can name only a user who signs in here through that provider, or a user whom the SCIM connection bound to that provider created. (SCIM is the protocol a company’s user directory uses to create and disable accounts in other systems by itself. A provisioning connection is the record that links one directory to one bucket.) Everyone else gets the same 404, even people who do not exist. So nobody can use the endpoint to find out who has an account.

The request ends everything the person has: sessions, grants (the permissions the user gave to applications), refresh tokens and opaque access tokens. An opaque token is a random string with no content; only the server knows what it stands for. Each relying party also gets a back-channel logout. The account itself stays active. The person can sign in again at once, but has to give consent again. The draft’s MUST, its required minimum, is smaller: revoke the refresh tokens and make the user sign in again. The draft allows doing more.

Access can end along several paths: an administrator’s action, this request from Okta, a deactivation over SCIM. All of them go through one and the same operation, and that was a deliberate decision. Our administrators get the same thing from the “Sign out everywhere” button.

Measure an assertion’s lifetime from iat, not nbf, or you refuse every real request

Look at the three times in that payload (the content of the JWT). iat is when the assertion was issued. nbf (“not before”) is the moment before which it is not valid. exp is when it expires. nbf is five minutes before iat, and exp is five minutes after. Okta does this on purpose: if the receiver’s clock is a little off from Okta’s, the request still passes. That is entirely legitimate.

We cap the lifetime of a presented assertion at 300 seconds. The reason is replay: someone who intercepts an assertion can send it again. The longer it lives, the longer that window stays open. The obvious way to measure a JWT’s lifetime is from the moment it becomes valid to the moment it stops being valid: exp - nbf. For Okta’s assertion that is 600 seconds. Measure it that way and the server refuses every real request. And the refusal looks like a correct security check.

Okta’s assertion timeline: nbf five minutes before iat, exp five minutes after; measured from nbf the lifetime is 600 seconds and over the 300-second cap, measured from iat it is 300 seconds and within itnbf−300 siat (now)0exp+300 sexp − nbf = 600 s: over the cap, every real request refusedexp − iat = 300 s: acceptedOne assertion, two ways to measure how long it lives

So we measure the lifetime from the issue time (iat) and from now. We check nbf only for what it means: before that moment, the assertion is not valid. That check is done by jwtVerify from the jose library, before our own code runs:

const { exp, iat, jti } = claims;
if (typeof exp !== 'number' || typeof iat !== 'number')
throw refused('lifetime');
if (typeof jti !== 'string' || !jti) throw refused('identifier');
const now = epochTime();
if (iat > now + clockTolerance) throw refused('issued_in_future');
if (
exp - iat > MAX_LIFETIME_SECONDS + clockTolerance ||
exp - now > MAX_LIFETIME_SECONDS + clockTolerance
) {
throw refused('lifetime');
}

The checks against now are the half people leave out. Take an assertion dated tomorrow, with a five-minute lifetime. It passes the exp - iat check perfectly well. If that were the only check, the assertion would stay valid for a whole day. So we refuse an iat that lies in the future, and we cap how much time is left from now until exp. That closes the gap. And it means we trust the provider’s clock for one thing only: which way time runs.

The checks are fixed in code, because a check you can switch off will be switched off and forgotten

An endpoint that ends sessions is a lever over a whole workforce: with it, someone can cut off a company’s employees. To pull that lever, an attacker needs three things: the provider’s issuer (the iss value), our client id at that provider, and one user’s subject there. None of them is a secret. So every check on this endpoint is written as if the request came from an attacker. A fair share of the requests that will ever reach it will.

Keycloak’s CVE-2026-18569, from August, is the case I keep in front of me. The part of Keycloak that receives back-channel logouts accepted logout tokens signed with alg: none, that is, with no signature at all. It did so for any provider whose signature validation an operator had switched off. I doubt anyone switches signature validation off in production on purpose. I suspect they switch it off once, to debug a provider that will not connect, and forget. So in our server the set of acceptable signing algorithms is a constant. It holds only asymmetric algorithms, and of those only the ones the provider advertises. No setting can widen it: not a provider’s, not a bucket’s, not the whole instance’s. none and every HS* are refused. HS* algorithms are symmetric: whoever checks the signature must hold the same key that made it. And nobody but the provider may hold the key that signs these tokens.

The other checks follow the same rule: each is a constant, and none can be configured.

The provider is looked up by issuer and client id as two separate values, never as one key glued together from both. The reason is Keycloak #42209. There, brokered logout (logout for users who signed in to Keycloak through an outside provider) broke for any provider whose alias contained a dot.

The aud (whom the assertion is meant for) must be exactly this endpoint. exp, iat and jti are required. jti is the assertion’s unique id, and each jti is accepted only once.

To remember which jti values we have seen, we record each one under a namespace made of the bucket and the provider. That namespace ends with a delimiter. The delimiter is there because the record’s id is a hash of the namespace and the jti glued together with nothing in between. Without it, two different namespace and jti pairs could glue into the same string.

If the feature is not switched on for a provider, the provider gets 403. But only after it has authenticated. So a caller who has not authenticated learns nothing from the refusal about which providers have the feature switched on. And every attempt with a bad credential counts against the strict per-origin rate limit, the cap on requests from one origin. It is the same counter the SCIM endpoint uses.

Keep the pause between key fetches, or every forged request hits the provider’s server

There is one more trap, and it is in the key cache, not in the checks. By default the jose library waits at least thirty seconds between two fetches of a provider’s key set. That pause is called the cooldown. On the federated sign-in path (when a user signs in to us through an outside provider), our server switches the cooldown off. We switched it off because of the key-rotation test in the OpenID Foundation conformance suite, the tests that check a server follows the standard. It showed that after every key change at the provider (a rotation), sign-ins failed for half a minute. And on that path the cooldown defends against nothing. We fetch the ID token ourselves, from the provider’s own token endpoint. So an unknown kid (the id of the key that signed a token) can only come from the provider itself.

A revocation request is the opposite. Anyone can send one, with any kid they like. And a cache without a cooldown fetches the provider’s keys again for every unknown kid. That lets anyone aim our server at the customer’s identity provider: each forged request makes our server fetch the provider’s keys once. So one provider has two key caches. The one for JWTs that callers present to us keeps the cooldown:

export function presentedKeySetFor(jwksUri: string): RemoteKeySet {
const existing = presentedSets.get(jwksUri);
if (existing) return existing;
/* (the bound on how many providers are held is left out here) */
/* jose's default 30 s cooldown: a genuine rotation is honoured once per cooldown. */
const created = createRemoteJWKSet(new URL(jwksUri), {
[customFetch]: fetchThroughBoundary
});
presentedSets.set(jwksUri, created);
return created;
}

The cost: we honour a genuine key rotation at Okta at most once every thirty seconds. A revocation that arrives inside that window, signed with the new kid, is refused. I would take that trade every time over an endpoint that fetches keys whenever anyone asks.

Okta’s draft is one module on a shared core, so the next format got every check for free

The core under all of this does three things and knows nothing about Okta. It authenticates an upstream provider of this bucket. It decides whom that provider may name. And it ends that person’s access. The global token revocation module adds only the details: which claim (field of the token) carries our client id, which audience (aud) it expects, which token types it accepts, a namespace for replay records, and which provider option switches it on.

That split paid for itself almost at once. Back-channel logout that Keycloak, Auth0 and Ping send to us is the second module on the same core. It inherited every refusal described above, and none of them had to be written again. The only thing it had to change was where our client id lives. A logout token carries it in aud; Okta’s assertion carries it in sub. Each format declares that place itself; the core does not guess it. If it guessed, a token could choose which provider it is checked against. Back-channel logout has been run against a real Keycloak 26.8.0. A CAEP session-revoked event, if any provider ever sends one, would be a third module.

When I would turn it on, and why it stays off by default

I would turn it on for a deployment whose customers run Okta with Identity Threat Protection and expect Universal Logout to reach every application. Identity Threat Protection is the Okta licence under which Okta sends Universal Logout. Even then, I would document the switch as experimental, and someone would watch the refusal events for the first week. Everywhere else I would leave it off, because an endpoint you do not need is one more way in.

And I would be honest about the state of it, as I am trying to be here. It has not been run against a real Okta organisation, because that needs Identity Threat Protection. The tests replay the request as Okta documents it, signed with the keys of a stub provider (a fake provider made for the tests). Two questions stay open. First, Okta documents its sub as client_id/appInstanceId. That may not be the bare client id the provider is configured with. Second, a provider can be configured with the issuer of a custom authorization server (a separate authorization server inside Okta). That issuer may not match the organisation’s issuer that Okta’s request carries. Only a real run will settle either question.

The SCIM side, which usually comes along with Universal Logout, has its own gaps. Okta’s CRUD test (the check that users can be created, read, updated and deleted) has not been run, and there is no /Bulk endpoint. None of it has been put forward for certification. If the draft is revived and changes, the module changes with it. If it stays dead, Okta will presumably move to something else. And the core will still be the part worth having written.