The account is suspended, but the token still works
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.
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.
// 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}`);| Approach | If intercepted |
|---|---|
| Implicit flow | The token is used as is (no longer recommended) |
| Authorization code + embedded client secret | Unpacking the app reveals the secret |
| Authorization code + PKCE | Rejected 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.
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.
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.
- Alice: Removes Bob from sharing
- Authorization service: Permissions move to v2
- Alice: Saves a document along with v2
- Bob: Tries to open the document
- Authorization service: Cache is at v1, but decides at v2 or newer
- 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.
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 lifetime | Use after suspension | Refreshes for 8 hours of use a day |
|---|---|---|
| 24 hours | up to 24 hours | at most once a day |
| 1 hour | up to 1 hour | 8 a day |
| 15 minutes | up to 15 minutes | 32 a day |
| 5 minutes | up to 5 minutes | 96 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
| Checkpoint | If you forget to check | How to guard it |
|---|---|---|
| Receive | An intercepted code yields tokens | PKCE |
| Verify | Tokens meant for another app get through | aud and iss |
| Touch data | Changing an ID shows someone else's data | Ownership checks, row-level security |
| Cache decisions | Documents added right after removal are visible | Store the permission version with the document |
| Refresh | A stolen refresh token keeps working | Rotation and reuse detection |
| Revoke | Tokens work until expiry after suspension | Short 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.
- Keep access tokens short-lived (say, 15 minutes)
- Revoke through server-managed refresh tokens
- On suspension or a password change, revoke the whole set of refresh tokens
- Check revocation on the server only for critical operations such as transfers
- 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
- SPAs and mobile apps cannot keep secrets: bind the authorization code to the requesting app with PKCE
- A token with a valid signature is not necessarily meant for you: check aud and iss
- Hard-to-guess IDs are not a substitute for authorization
- Right after you revoke access, a cached authorization check still says yes (the new enemy problem)
- Rotate refresh tokens on every use, and revoke the whole set when a used one comes back
- Self-contained JWT verification alone does not learn about later revocation