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.
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.