Session Revocation and Logout Across Devices
“Sign out of all devices” is a promise, and in a distributed system it is surprisingly easy to break. If sessions are stateless bearer tokens with an hour of life left, the button changes nothing for that hour. If validation results are cached at the edge, revocation takes effect whenever the cache decides. Users press that button precisely when they believe someone else is inside their account, which is the worst possible moment for the promise to be approximate.
This guide builds revocation that works and is honest about its limits: a session registry, a credential version that invalidates stateless tokens, a user-facing device list, and a measured propagation delay. It is part of the Session Fixation Prevention guide within Secure Authentication & Session Architecture, and it is what password reset flows depend on when they revoke everything.
Prerequisites
- A session or token store reachable from every service that authenticates requests
- A user record able to carry a monotonic credential version
- Device metadata captured at login: user agent, address, first and last seen
- A place to show users their active sessions
Expected Outcomes
- Every issued session recorded and individually revocable
- Stateless tokens invalidated by a version bump on any credential change
- A security page listing devices, with per-device and revoke-all controls
- A measured, stated worst-case delay before revocation is effective everywhere
Step 1: Keep a Registry of Issued Sessions
type SessionRecord = {
id: string; // opaque identifier, not the cookie value
userId: string;
createdAt: number;
lastSeenAt: number;
device: { ua: string; platform: string; label: string };
ip: string; // coarse, for the user's benefit
assuranceMethod: 'password' | 'totp' | 'webauthn';
revokedAt: number | null;
};
export async function issueSession(user: User, req: Request) {
const id = crypto.randomUUID();
await sessions.put({
id, userId: user.id, createdAt: Date.now(), lastSeenAt: Date.now(),
device: describeDevice(req), ip: coarseIp(req), assuranceMethod: req.assurance.method,
revokedAt: null,
});
return signCookieValue(id); // the cookie carries the id; the record carries the state
}
Storing the record separately from the cookie is what makes revocation possible at all: the server can mark the record revoked without needing the client to cooperate. Keep the device label human-readable, because a list of unlabelled entries is one nobody acts on.
Step 2: Version the Credential for Stateless Tokens
Where a service validates a token without a store lookup, the version claim is the revocation mechanism.
# Issued into every access token.
claims = {
"sub": user.id,
"cv": user.credential_version, # bumped on password change, factor change, revoke-all
"sid": session_id, # so an individual session can be named
"exp": now + 900, # short life keeps the version re-read frequently
}
# Checked on every request, from a cheap cache with a short lifetime.
def validate(claims) -> bool:
current = credential_versions.get(claims["sub"]) # cached, invalidated on bump
if claims["cv"] != current:
return False # every older token is now stale
if sessions.is_revoked(claims["sid"]):
return False # individual revocation
return True
The version handles the broad case — “everything issued before this moment is void” — while the session identifier handles the narrow one. Together they cover both buttons on the security page without a store lookup that has to be perfectly consistent.
Step 3: Give Users a Device List They Can Act On
app.get('/account/security/sessions', requireSession, async (req, res) => {
const list = await sessions.activeFor(req.session.userId);
res.json(list.map((s) => ({
id: s.id,
label: s.device.label, // "Chrome on macOS", not a raw user-agent string
location: approxLocation(s.ip), // city-level, clearly marked approximate
lastSeen: s.lastSeenAt,
current: s.id === req.session.id,
signedInWith: s.assuranceMethod,
})));
});
app.post('/account/security/sessions/:id/revoke',
requireSession, requireAssurance('session.revoke'), async (req, res) => {
await sessions.revoke(req.params.id, req.session.userId); // scoped: only your own
await audit.write('session.revoked', { by: req.session.id, target: req.params.id });
res.status(204).end();
});
Two details make this useful rather than decorative. Revocation is scoped to the caller’s own sessions, so the endpoint is not an object-level authorization failure waiting to happen. And it requires a recent factor, because an intruder holding a session should not be able to evict the legitimate owner.
Verification
# 1. Revoking one device leaves the other alive.
A=$(login_as alice); B=$(login_as alice) # two sessions
SID_B=$(curl -s -H "Cookie: $A" localhost:3000/account/security/sessions | jq -r '.[] | select(.current==false) | .id')
curl -s -X POST -H "Cookie: $A" localhost:3000/account/security/sessions/$SID_B/revoke
curl -s -o /dev/null -w '%{http_code}\n' -H "Cookie: $B" localhost:3000/api/me # expect 401
curl -s -o /dev/null -w '%{http_code}\n' -H "Cookie: $A" localhost:3000/api/me # expect 200
# 2. A credential change kills every stateless token.
curl -s -X POST -H "Cookie: $A" localhost:3000/account/password -d '{"password":"…"}'
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $OLD_ACCESS" localhost:3000/api/me
# Expect 401 within the version cache lifetime, not at token expiry.
# 3. Measure the real propagation delay across every service that validates.
for SVC in api search reports export; do
curl -s -o /dev/null -w "$SVC %{http_code}\n" -H "Authorization: Bearer $REVOKED" https://$SVC.internal/health-auth
done
The third check is the one that surfaces the service everyone forgot — usually a reporting or export service with its own cache and a much longer lifetime than the others.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Revoked users keep working for an hour | Long-lived access tokens with no version check | Add the version claim, shorten access token lifetime, keep refresh rotation |
| Revocation works in one service, not another | Independent caches with different lifetimes | Read the version from one shared source with a short lifetime and a change event |
| Users cannot identify their own devices | Raw user-agent strings shown | Derive a human label and show approximate location and last-seen time |
| Revoke-all also drops the current session unexpectedly | No exclusion for the calling session | Offer both variants explicitly and label them clearly |
| Intruder revokes the owner’s sessions | Revocation endpoint requires only a session | Require a recent factor for revocation, as for any security-relevant operation |
| Sessions list grows forever | Records never pruned | Expire records past their maximum lifetime and archive revoked ones |
Common Implementation Mistakes
Frequently Asked Questions
How do you revoke a stateless token before it expires?
You cannot revoke the token, so you invalidate what it asserts. Carry a credential version in the token, keep the current version with the user, and bump it whenever something security-relevant changes — every previously issued token then fails on its next use. Keep access tokens short so the version is consulted frequently, and hold the version somewhere every service can read cheaply with a short cache lifetime.
Should logout end one session or all of them?
One by default, all on demand, and all automatically on a security event. Ending every session because someone signed out on a shared computer is unhelpful; leaving them alive after a password reset or a factor removal is dangerous. Offer both explicitly, list devices so the choice is informed, and revoke everything automatically whenever the credential changes.
What propagation delay is acceptable?
Whatever you are prepared to state publicly, and no more. With a shared registry consulted per request the delay is effectively zero. With cached validation it equals the cache lifetime. With long-lived stateless tokens it equals the remaining token lifetime — meaning an intruder keeps working for up to an hour after the victim pressed the button. If the number you measure would embarrass you in a security page, shorten the token lifetime until it does not.
Related
- Session Fixation Prevention — the parent guide covering identifier rotation at privilege change
- Password Reset Flows That Resist Account Takeover — the flow that depends on revoking everything
- Binding Refresh Tokens to a Device — making a stolen long-lived credential useless elsewhere
- Step-Up Authentication for High-Value Operations — the assurance revocation itself should require