---
title: "Deactivating a user does not end their access — FoxAuth"
description: "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."
canonical: "https://foxauth.dev/blog/deactivating-a-user-is-not-deprovisioning/"
lastmod: "2026-10-08T21:45:57+03:00"
---
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 · 2026-10-08

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.

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.

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.

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.

All articlesTry it yourself
