All posts

The account is suspended, but the token still works

· 10 min read

A departing employee's account was suspended in the evening, yet it could still read customer data the next morning. This post inspects, in order, the six checkpoints a token passes through from issue to expiry.

The incident: a suspended account could read data until morning

In an internal customer management system, a departing employee's account was suspended in the evening. The next morning's logs showed the account's token reading customer data overnight.

The access token was a JWT valid for 24 hours, and the API checked only signature and expiry. Suspending the account had no effect on tokens already issued.

Incidents like this rarely have a single cause. Between issue and expiry, a token passes through six checkpoints.

The six checkpoints a token passes through. This post inspects them in order

Receive → Verify → Touch data → Cache decisions → Refresh → Revoke

Receiving: bind the authorization code to the app that asked

In OAuth's authorization code flow, the app receives a short-lived code and exchanges it for tokens. Server-side apps use a client secret for the exchange, but SPAs and mobile apps cannot keep a secret: anyone can extract it from what they ship.

With PKCE, the app creates a random code_verifier for each login and sends only its hash with the first request. Only the requesting app can present the code_verifier at the exchange, so an intercepted code is rejected.

TypeScript: create PKCE values in an SPA and request authorization
// 32 random bytes in Base64URL is 43 characters. Create a new one for every login
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));
sessionStorage.setItem("pkce_verifier", verifier);

// Send only the challenge with the request
const params = new URLSearchParams({
  response_type: "code",
  client_id: clientId,
  redirect_uri: redirectUri,
  code_challenge: challenge,
  code_challenge_method: "S256",
  state,
});
location.assign(`${authorizeUrl}?${params}`);
ApproachIf intercepted
Implicit flowThe token is used as is (no longer recommended)
Authorization code + embedded client secretUnpacking the app reveals the secret
Authorization code + PKCERejected at the exchange without the code_verifier

Verifying: a valid signature does not mean it is meant for you

One IdP issues tokens to many apps, all signed with the same key. A signature shows only that "this IdP issued it," not that "it was issued for my API."

At a company whose APIs checked only signature and expiry, a token from a study-group app anyone could register let people read salary data from the HR API. The recipient is in aud, and the issuer in iss.

Go (golang-jwt/jwt v5): check recipient and issuer too
token, err := jwt.Parse(raw, keyFunc, // keyFunc returns the IdP's public keys (JWKS)
	jwt.WithValidMethods([]string{"RS256"}),       // never follow the alg in the header
	jwt.WithIssuer("https://idp.example.com/"),     // the issuer must match exactly
	jwt.WithAudience("https://hr-api.example.com"), // the token must be meant for this API
	jwt.WithExpirationRequired(),                   // reject tokens without an expiry
)

Issue tokens with a different aud per service, so no single token can call every API.

Touching data: logged in does not mean allowed to see it

In an invoicing SaaS used by 2,000 companies, the API took an ID, as in /invoices/1002, and checked only that the user was logged in. Changing the number in the URL by one showed another company's invoice. This is BOLA (broken object level authorization).

UUIDs make IDs harder to guess, but the same thing happens once an ID leaks through a shared link or a log. Have the data access layer always add the tenant ID, and use the database's row-level security as the last line of defense.

SQL: restrict rows to the tenant with row-level security (PostgreSQL)
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;  -- apply it to the table owner too
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Set the tenant ID from the verified token, for this transaction only
BEGIN;
SELECT set_config('app.tenant_id', $1, true);
SELECT * FROM invoices WHERE id = $2;  -- another company's invoice returns zero rows
COMMIT;

Take the tenant ID from the verified token, never from the URL or the body. If the previous checkpoint skipped aud and iss, that ID cannot be trusted either. A query that forgot to set it makes current_setting raise an error, so it fails instead of seeing every row.

Caching decisions: a removed permission lingers

Permission checks are frequent, so they usually come from a cache. An internal document-sharing system cached them for 5 minutes. Right after a transferred employee was removed from a project, a performance review went into its folder. The cache still said "can read," and the review was opened 4 minutes later.

Making every check fresh would be too slow. Instead, store the permission version at write time (a ZedToken in SpiceDB) with the document, and require a decision at that version or newer when reading. Unchanged documents keep the fast cached decision.

Store the permission version with the document, and decide at that version or newer
  1. Alice: Removes Bob from sharing
  2. Authorization service: Permissions move to v2
  3. Alice: Saves a document along with v2
  4. Bob: Tries to open the document
  5. Authorization service: Cache is at v1, but decides at v2 or newer
  6. Bob: Cannot read it

Refreshing: replace the refresh token on every use

Short-lived access tokens still ride on refresh tokens for long sessions. If a refresh token is stolen, the attacker can keep getting new access tokens every time one expires.

So replace it with a new one on every use (rotation) and invalidate the old one. Either the thief or the legitimate app will then present a used token, and on detecting that, invalidate the whole chain from the same login.

The trap is the legitimate app presenting a used token itself. A user with three tabs open had all three refresh at once; the second and third were judged as reuse, and the user was logged out almost daily.

TypeScript: funnel refreshes into one, even across tabs
let refreshing: Promise<void> | undefined;

export function refreshTokens(): Promise<void> {
  // Parallel requests in the same tab wait for the refresh in progress
  refreshing ??= navigator.locks
    .request("token-refresh", async () => {
      // While we waited for the lock, another tab may already have refreshed
      if (!expiresSoon(storedTokens())) return;
      await exchangeRefreshToken();
    })
    .finally(() => {
      refreshing = undefined;
    });
  return refreshing;
}

Adding a short grace period for dropped connections (30 seconds by default in Okta) nearly eliminates false logouts.

Revoking: a JWT cannot be stopped before it expires

The last checkpoint is the incident's direct cause. A JWT can be verified without asking the authorization server, but there is no way to invalidate one after issuing it. Suspend the account or change the password, and it still works until expiry.

So set the lifetime from "how long may it be used after we stop it?" and revoke through refresh tokens. The shorter the lifetime, the smaller the hole, and the more often clients refresh.

Access token lifetimeUse after suspensionRefreshes for 8 hours of use a day
24 hoursup to 24 hoursat most once a day
1 hourup to 1 hour8 a day
15 minutesup to 15 minutes32 a day
5 minutesup to 5 minutes96 a day

For operations that must stop immediately, such as transfers or permission changes, check revocation on the server despite the extra latency. Passing checkRevoked to Firebase Authentication's verifyIdToken is one example.

The six checkpoints at a glance

CheckpointIf you forget to checkHow to guard it
ReceiveAn intercepted code yields tokensPKCE
VerifyTokens meant for another app get throughaud and iss
Touch dataChanging an ID shows someone else's dataOwnership checks, row-level security
Cache decisionsDocuments added right after removal are visibleStore the permission version with the document
RefreshA stolen refresh token keeps workingRotation and reuse detection
RevokeTokens work until expiry after suspensionShort lifetimes, refresh token revocation

When asked, "How do you implement logout with JWTs?"

State the premise first: "A JWT verified by signature alone cannot be revoked before it expires." Then answer in this order.

  1. Keep access tokens short-lived (say, 15 minutes)
  2. Revoke through server-managed refresh tokens
  3. On suspension or a password change, revoke the whole set of refresh tokens
  4. Check revocation on the server only for critical operations such as transfers
  5. Rotate refresh tokens and detect reuse

Raising "how many minutes may it be used after we stop it?" yourself gives your choice of lifetime a reason.

Tips this post digs into