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.

The Client Holds a Pointer; the Server Holds the State The cookie carries only an opaque identifier. Every attribute that matters — the owner, the device, the last-seen time, the assurance method and the revoked flag — lives in a record the server owns. Revocation is therefore a server-side write that needs no cooperation from the client at all. What the client holds an opaque identifier host-prefixed, script-inaccessible no claims, no state, no expiry logic Nothing here can be trusted, and nothing here needs to be. What the server owns owner, device label, address, first and last seen the assurance method that established it the revoked flag, and when it was set Revocation is a write on this side only — which is exactly why it works without the client agreeing.

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.

Three Mechanisms, Three Different Delays A registry lookup revokes one session immediately but costs a lookup per request. A credential version bump revokes everything issued earlier and takes effect as soon as the cached version expires. Waiting for token expiry revokes nothing actively and takes as long as the remaining token lifetime, which is why long-lived access tokens make honest revocation impossible. Mechanism Scope and delay Cost Registry lookup one session, effective immediately a store read per request, usually cached Version bump on the user record everything older, within the cache life one cached integer read per request Waiting for expiry nothing, until the token dies free, and it is not revocation

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.

What a Device Row Has to Tell the User Each row shows a readable device label rather than a raw user-agent string, an approximate location clearly marked as approximate, a last-seen time, the method used to sign in, and whether the row is the session the user is currently viewing from. Without those five fields, nobody can decide which row to revoke. Chrome on macOS · Amsterdam (approximate) · last seen 4 minutes ago signed in with a security key · this device — the row the user is reading from Safari on iPhone · Amsterdam (approximate) · last seen 3 days ago signed in with a one-time code · revoke Firefox on Windows · Frankfurt (approximate) · last seen 2 hours ago signed in with a password only · revoke — the row a user would actually act on

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.