Skip to content
FoxAuth

Blog

Deactivating a user does not end their access

A flag checked at the next sign-in leaves every session and token alive. How to end access at once, in what order, and what stays out of reach.

By FoxAuth ·

We had this bug ourselves, in version 0.7.0 of our server (we fixed it in 0.8.0). Here is what it looked like. We disable a user in the console. We take the access token their application received ten minutes earlier and ask the server whether that token is still valid (this request is called introspection). The answer is "active": true: it is valid. We open the application in the user’s browser, and they are still signed in. The application’s logs are empty: nobody told it that the user had been disabled.

Formally, nothing was broken. The account carried the flag active: false, and the code checked it everywhere it looked the account up. A new sign-in: refused. A token refresh: refused. Sign-in through the device flow and CIBA (ways to sign in from another device): refused too. That is how most deactivation works. I built it the same way and thought it was enough. The check was in the right place if the question is “can this person get in?” But an administrator who disables someone (usually in a hurry) is asking something else: “is this person already out?” Those questions have different answers. The difference between them is every session and token issued before the button was pressed.

The active: false flag only stops the next sign-in, and issued sessions and tokens live on

Deactivation sets the flag active: false on the account. That flag is about the future: it stops the next sign-in. But the sessions and tokens the user received before deactivation are not touched by it. The check passed when they were issued, and nobody will run it again. The browser session on the authorization server stays valid until it expires. The access token stays valid until its exp (the expiry written into the token itself). If it is an opaque token (a random string with no contents, known only to the server), then when asked “is this token valid?” the server still answers “yes”. It looks at two things only: whether the token itself still exists, and whether the permission the user gave this application (the grant) still exists. It does not look at the active flag.

Finally, the application has its own session — the one the user actually sees. The application trusts our server to check sign-ins, so the standards call it a relying party. How long that session lives is up to the application. And nobody told it anything.

The picture below shows what happens after a user is disabled, in two versions of our server. At the top is 0.7.0: everything keeps working. At the bottom is 0.8.0, where this is fixed.

What survives a user’s deactivation over the next hour. Before, the relying party session, the server session and an opaque access token all lived on. Now everything ends at the moment of deactivation, except a self-contained JWT access token: it lives until it expiresdeactivated0.7.0relying party sessionserver sessionopaque access tokenintrospects active to exp0.8.0sessions, grants, tokensended; each relying party sent a logout tokenJWT access tokenlives to exp: 15 minutes by default0an hour later

So one flag is not enough. You also need an operation that finds and ends everything the flag cannot reach. We call it the sweep. And the order of its steps turned out to matter more than I expected.

First note whom to tell about the sign-out, then delete the sessions

The obvious solution is two lines: set active: false, then delete everything the user owns. That is a mistake, and a test of the “the sessions are gone” kind will not notice it. The relying parties have to be told that the user has signed out. OpenID Connect has back-channel logout for this: the server itself, without the browser, sends the application a logout token — a message saying “this session is over”. One such token per session. Each one carries sid, the session’s identifier. The application uses it to find its own matching session and close it. And sid is stored only in the session record. So the server must first read the sessions and note which relying parties to tell, and with which sid — and only then delete them. If you delete the sessions first, the sid values go with them, and there is nothing to send to the applications. The server has no sessions left, and in every application the user is still signed in.

So the order is built into the code. One function does both steps, always in the same order: first it notifies the applications, then it deletes the records:

export async function revokeAccountAccess(
accountId: string
): Promise<CascadeResult> {
await notifyRelyingParties(accountId);
return sweepAccountOwned(accountId);
}

notifyRelyingParties first reads all of the user’s sessions. The server could not do that before: the only way it had to find a user’s records was the same method that deleted them. So all three storage adapters gained a findByOwner method — it only finds records and deletes nothing.

If an application does not respond, the function does not stop. The server reports the failure as an event, the same as on an ordinary sign-out, and carries on. The user must lose access even if one of the applications has a sign-out address that is not working.

The order of steps when an administrator or a directory disables a user: the audit entry and the flag; reading the sessions; a logout token with the session id to each relying party; deleting every record the user owns; the answer — 200, or 500 listing what survivedAdmin or directoryAuthorization serverRelying parties1 active: false2 audit entry, then the flag: sign-in closed3 read every session (findByOwner)4 POST logout_token { sid }5 sweep sessions, grants, tokens, codesfrom every storage area of this user6 200, or 500 with failedAreasSwap 3 and 5 and every test of “the sessions are gone” passes while no relying party is told.

Now the sweep itself — deleting everything the user owns. It is not a list of tables someone wrote from memory. Records on our server are split into storage areas: sessions, tokens, codes and so on. Each area declares which of its fields holds the owner. The sweep simply deletes from every area everything that belongs to this user. So an area that appears in a future release will be swept automatically. Nobody has to remember to add it anywhere.

I have written the hand-maintained list more than once. It breaks silently as soon as a new area appears: its records outlive the user, and nobody notices. There is another obvious approach — walking the permissions (grants) issued to the user. But that misses most of the records. Sessions, sign-ins in progress, enrolled second factors and email-verification requests hang off the user, not off a grant.

Token checks (introspection) did not need to change. Introspection already answers active: false as soon as the grant the token was issued under is gone. So when the sweep deletes the grants, every opaque token stops working at once.

The sweep leaves the failed-sign-in counters alone: they keep people out, not in

The sweep deliberately leaves two areas alone: the counter of failed sign-in attempts and the counter of password-reset attempts. They are tied to an email address, not to the user. It may look as if they were forgotten. They were not. These counters let nobody in — they count how many times someone has tried to guess the password for that address. And you are most likely to be disabling a user in a hurry exactly while they are under attack. Reset the counter at that moment and the attacker gets fresh attempts in the middle of the incident. Hence the rule: the sweep deletes what lets someone in and keeps what keeps someone out.

If the sweep misses something, the server answers 500 and names what is left

In our admin console, the audit trail has two rules. First: the entry is written before the change. Second: the trail can only be appended to, and old entries never change. Both rules are right. But together they rule out the simplest way to report a partial failure — adding “these three areas did not clear” to an entry written before the attempt.

So if something is left after the sweep, the deactivation answers 500 and lists the areas that did not clear. By then the user can no longer sign in: the flag is set first. The leftover records live no longer than their area’s usual expiry, and they never let anyone back in. To finish the job, just disable the user again. The server will not answer “already disabled”; it runs the sweep again. A repeat that does something every time looks strange — until you need to recover from a failure like this.

The directory will overwrite the active flag, so a separate lock is needed

Users can be managed by a corporate directory, such as Entra or Okta. The directory itself creates, changes and disables users on our server over the SCIM protocol — this is called provisioning. For such users the active flag belongs to the directory: the directory writes it on every sync. Say an administrator disables such a user by hand on our side during an incident. A few minutes later the scheduled sync writes the flag again and quietly reverses the administrator’s decision. That is not a protection anyone should rely on.

A user can sign in only if two flags allow it. active is written by whoever manages the account (for a provisioned user, the directory). The local lock is written only by an administrator. Sign-in needs active and no lockDirectory over SCIMAdministratoractivelockedLocallycanSignInactive and not lockedNothing a directory sends reaches the lock, so its next sync cannot undo an incident response.

So we have a second flag — the lock. Only an administrator sets and clears it; the directory cannot reach it. One function decides whether to let the user in: it checks both flags, and every sign-in path calls it. That matters as much as the flag itself. If the “let them in?” check exists in two copies, over time they drift apart, and a locked user gets through the copy that fell behind. That has already happened to us. Sign-in through an external identity provider (an upstream provider) checked only active. A locked user already linked to such a provider passed that step. Only the next check stopped them.

The lock departs from the OpenID IPSIE profile (Interoperability Profiling for Secure Identity in the Enterprise — a set of security requirements for corporate sign-in systems). The profile forbids changing users locally when a directory manages them. I think this departure is justified. The lock touches only sign-in and does not change the user’s data. And an incident response that waits for someone else’s system to sync is not an incident response.

Locking runs the same sweep as deactivation. Unlocking, though, we made harder. Our server can also be managed by an AI agent — through MCP (the protocol an AI agent uses to call external tools). For an agent, unlocking counts as a high-consequence operation and will not go through without confirmation. After all, it lets back in a user an administrator judged compromised.

One more thing: neither reactivating nor unlocking restores anything. The user’s consents (their “allow” for each application) were deleted along with the grants. So after signing in, the user consents again. It surprises people the first time. But that is exactly what IPSIE means by “all access mechanisms and authorizations”.

A JWT access token cannot be revoked: it lives until it expires

There is one thing the sweep cannot reach. It is better to say so plainly than to promise that “deactivation ends access at once”. It is the JWT access token. The resource server (the API that accepts the token) checks it on its own, against the published keys, and never asks the authorization server about it. So there is nothing for us to intercept and nothing to delete. Such a token stays valid until its exp.

The only remedy left is a short token lifetime. For a declared resource (an API or MCP server an administrator has registered as a protected resource) our server issues tokens for fifteen minutes. An administrator can raise that, but to no more than a day. IPSIE SL2 allows fifteen minutes for an ordinary token and sixty for one bound to the client’s key with DPoP. That is a reasonable limit: it is how long an organisation should be ready for a departed employee’s token to keep working. If a resource server cannot wait even that long, it can ask for opaque tokens and check each one through introspection. That costs an extra network round trip per request, but the user’s deactivation shows up at once. Most resource servers I have worked on would choose the fifteen minutes.

When to deactivate, when to lock, when to sign out everywhere, and when to delete

Deactivate when the person should no longer sign in and you manage the user. Lock when sign-in has to close right now and a directory manages the user — or when you do not trust its next sync. Press “Sign out everywhere” when the person is fine but their device is not. That button ends all sessions with the same sweep but leaves the user active. Delete when the account itself should disappear. That also ends access: the same steps run before deletion.

Some of the machinery around the sweep arrived later than the sweep itself. A directory connects to our server through a provisioning connection — a record that links one directory to one bucket of users. A bucket is what our server calls a group of users with their own sign-in rules. Once a bucket has its own address, it issues tokens in its own name, that is, it becomes a separate issuer. If a connection starts disabling too many users at once, the connection is put on hold. That safeguard has passed its tests. But Okta’s CRUD test (checking that users are created, read, updated and deleted) has not yet been run against a real Okta organisation. There is no /Bulk endpoint. Okta Universal Logout calls the same sweep from the external provider’s side, but it has never been run against a real Okta organisation either. None of this has been submitted for certification. The sweep itself, though, we tested end to end on a running server: the logout token delivered, the refresh and the introspection refused, consent asked for again after reactivation. That is the part I would copy.