---
title: "Okta Universal Logout is an expired draft — FoxAuth"
description: "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."
canonical: "https://foxauth.dev/blog/okta-universal-logout-is-an-expired-draft/"
lastmod: "2026-10-08T21:45:57+03:00"
---
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 · 2026-10-08

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.

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.

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.

All articlesTry it yourself
