← Recipes

阅读中文版本

Audit JWT Refresh Token Rotation and Revocation

Inspect access and refresh token lifetimes, verify refresh rotation invalidates the old token, and test revocation actually blocks reuse.

~30 minsecurityauthenticationjwt

Task

Audit a JWT-based auth system for token lifetime hygiene, refresh rotation correctness, and working revocation paths.

When to use this

An audit question: if a refresh token leaks, how long does the attacker keep access? Nobody knows because the lifetimes and rotation behavior were never written down.

Prerequisites

  • Access to the auth configuration and token issuance code
  • A test account for issuing and replaying tokens
  • The token storage design, such as a sessions table or Redis

Steps

1. Decode tokens and read the actual lifetimes.

echo "$ACCESS" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{exp, iat, scopes: .scope}'; echo "$REFRESH" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{exp, jti}'

Expected output: The decoded payloads show the real numbers, for example a 15-minute access token and a 30-day refresh token.

2. Verify refresh rotation invalidates the predecessor.

curl -s -X POST https://api.example.com/auth/refresh -d "refresh_token=$REFRESH" | jq -r .refresh_token > /tmp/r2; sleep 1; curl -s -o /dev/null -w "%{http_code}\n" -X POST https://api.example.com/auth/refresh -d "refresh_token=$REFRESH"

Expected output: The second use of the original refresh token must return 401; a 200 means tokens can be replayed forever.

3. Test that revocation blocks a live token.

curl -s -X POST https://api.example.com/auth/logout -d "refresh_token=$R2"; curl -s -o /dev/null -w "%{http_code}\n" -X POST https://api.example.com/auth/refresh -d "refresh_token=$R2"

Expected output: After logout, the refresh endpoint rejects the same token: the denylist or session delete works.

4. Check rotation detects token theft.

if (await store.isConsumed(jti)) {
  // the same refresh token was used twice
  await revokeFamily(familyId);   // kill every descendant
  throw new UnauthorizedError("token reuse detected");
}

Expected output: Replay of a consumed token revokes the whole family, so a stolen token cannot be laundered through rotation.

5. Write the audited policy down as code defaults.

const ACCESS_TTL = "15m";
const REFRESH_TTL = "14d";
const REFRESH_ROTATION = true;
const REUSE_REVOKES_FAMILY = true;

Expected output: The measured behavior now matches a documented default, and future changes are diffs against a policy, not archaeology.

Troubleshooting

Problem

Refresh tokens never rotate

Solution

The endpoint returns the same token until expiry; implement rotation with a consumed-token check, which also enables theft detection.

Problem

Rotation breaks concurrent tabs

Solution

Two tabs refresh simultaneously and one gets invalidated; serialize refreshes per user with a short mutex, and let the loser retry with the new token.

Problem

Access token cannot be revoked

Solution

Stateless JWTs are unverifiable against a denylist without extra state; keep access lifetimes minutes-short and put revocation power on the refresh token.

Limitations

  • Short access tokens bound revocation latency; if the business needs instant cutoff, JWT statelessness is the wrong trade.
  • Rotation plus family revocation detects reuse, not the initial theft; pair it with device binding for sensitive accounts.

The audit question: what does a leak cost

Every token design answers one question implicitly: if this string leaks, for how long is it useful? The answer is arithmetic over lifetimes plus behavior over rotation and revocation. Decode the real tokens, replay them at the refresh endpoint, and revoke one deliberately. Three measurements replace a vague feeling of security with a number, and the number either satisfies the policy or names the fix.

# 1. Read the real lifetimes
echo "$REFRESH" | cut -d. -f2 | base64 -d | jq '{exp, jti}'

# 2. Rotation must invalidate the predecessor
curl -X POST .../auth/refresh -d "refresh_token=$R1" > /tmp/r2
sleep 1
curl -X POST .../auth/refresh -d "refresh_token=$R1"   # expect 401

Rotation is theft detection

A refresh token that can only be used once turns replay into an alarm: if the server sees a consumed token again, either a bug or a thief is holding a copy. Revoking the entire token family at that moment means the attacker and the victim both log in again, and the attacker next attempt fails. Rotation is not just hygiene; it is the only way a stateless system notices theft at all.

Short access tokens are your revocation latency

A stateless access token cannot be checked against a denylist without paying back the statelessness. The honest design accepts that and caps the exposure: access tokens live minutes, refresh tokens carry the revocation power, and logout kills the refresh side. Instant global cutoff then has exactly one honest implementation, and it is not a JWT.