The Setup That Feels Correct
Most JWT implementations follow the same pattern: a short-lived access token (15 minutes), a long-lived refresh token (7–30 days). The access token goes in memory or a response body. The refresh token goes somewhere more durable — usually a cookie, sometimes localStorage.
The theory: even if an access token is stolen, it expires in 15 minutes. The refresh token is stored more carefully, so it's harder to steal.
The problem is that the 15-minute expiry only limits damage if the refresh token can actually be revoked. In most implementations, it can't.
Problem 1: The Refresh Token Is Stateless Too
JWTs are stateless by design. The server doesn't store them — it signs them, hands them out, and verifies signatures on incoming requests. No database lookup. That's the point.
Stateless also means you can't revoke them. You can't mark a token as invalid in a database because you never tracked it in the first place.
A user changes their password. You want all existing sessions to end. With a stateless refresh token, the only option is to wait for the token to expire. If it's a 30-day token, that session — the one created before the password change — stays live for 30 days.
The 15-minute access token buys you 15 minutes. The 30-day refresh token is what determines the actual breach window.
Problem 2: Rotation Without a Store Is Incomplete
Refresh token rotation sounds like a fix. Each time a refresh token is used, issue a new one and mark the old one invalid. Detect reuse of an already-used token as a signal of theft.
The gap: rotation requires tracking which tokens have been used. You're storing state again.
The naive version:
@app.route('/auth/refresh', methods=['POST'])
def refresh():
refresh_token = request.cookies.get('refresh_token')
payload = verify_jwt(refresh_token) # checks signature + exp only
new_access = create_access_token(payload['user_id'])
new_refresh = create_refresh_token(payload['user_id'])
response = jsonify({'access_token': new_access})
response.set_cookie('refresh_token', new_refresh, httponly=True, secure=True)
return response
verify_jwt checks the signature and the exp claim. That's all. The old refresh token is still cryptographically valid — it hasn't expired. If an attacker stole it before this exchange, they can use it right now to get a fresh access token.
The correct version requires a token table:
import secrets
from datetime import datetime, timedelta
def create_refresh_token(user_id):
token = secrets.token_urlsafe(32)
expires_at = datetime.utcnow() + timedelta(days=7)
db.session.add(RefreshToken(
token=token,
user_id=user_id,
expires_at=expires_at,
used=False
))
db.session.commit()
return token
@app.route('/auth/refresh', methods=['POST'])
def refresh():
raw_token = request.cookies.get('refresh_token')
record = RefreshToken.query.filter_by(token=raw_token).first()
if not record:
abort(401)
if record.used:
# Reuse detected — invalidate all sessions for this user
RefreshToken.query.filter_by(user_id=record.user_id).delete()
db.session.commit()
abort(401)
if record.expires_at < datetime.utcnow():
abort(401)
record.used = True
db.session.commit()
new_access = create_access_token(record.user_id)
new_refresh = create_refresh_token(record.user_id)
response = jsonify({'access_token': new_access})
response.set_cookie('refresh_token', new_refresh, httponly=True, secure=True)
return response
Now revocation is a database delete. Password change: RefreshToken.query.filter_by(user_id=user_id).delete(). Account suspension: same call. Logout: delete the specific record.
Problem 3: The Blocklist Approach
The alternative to tracking all tokens is tracking only revoked ones. Smaller write surface. Requires Redis or a similar fast store.
# On logout — store the raw token with a TTL matching its remaining lifetime
def revoke_refresh_token(token, exp_timestamp):
ttl = int(exp_timestamp - datetime.utcnow().timestamp())
if ttl > 0:
redis_client.setex(f'revoked_refresh:{token}', ttl, '1')
# On refresh
def is_revoked(token):
return redis_client.exists(f'revoked_refresh:{token}')
Setting the TTL to the token's remaining lifetime means the blocklist entry expires when the token would have anyway. You don't need a cleanup job.
The downside: this requires a Redis lookup on every /auth/refresh call. For most apps that's negligible. If you're on a tight latency budget, measure it — SISMEMBER or EXISTS on a local Redis is under a millisecond in practice.
The blocklist approach also doesn't give you reuse detection. You'd need the full token store for that.
Problem 4: alg: none
Less common in 2026 but still bites teams on old dependencies. The JWT header specifies the signing algorithm. Some library versions, if not told which algorithm to expect, will read it from the header — including "alg": "none", which means no signature required.
# Broken — library trusts whatever alg is in the token header
payload = jwt.decode(token, secret)
# Correct — pin the algorithm
payload = jwt.decode(token, secret, algorithms=["HS256"])
Modern PyJWT raises a DecodeError if algorithms isn't passed. Older versions don't. Run pip show PyJWT and check the version. If you're below 2.0, either upgrade or explicitly pass algorithms=.
Grep your codebase for jwt.decode( and check every call site has algorithms=.
Problem 5: Clock Skew on exp
The exp claim is a Unix timestamp in UTC. If your server's clock drifts, tokens that should be expired look valid.
5 minutes of clock skew on a 15-minute access token means sessions live for up to 20 minutes after they should end. This happens more on cheap VPS instances than you'd expect.
PyJWT accepts a leeway parameter:
payload = jwt.decode(token, secret, algorithms=["HS256"], leeway=30)
30 seconds of leeway for legitimate clock variation is reasonable. Anything over a minute is papering over a real problem. Fix the clock instead:
timedatectl status
# check: "NTP synchronized: yes"
# If not:
timedatectl set-ntp true
What the Stack Looks Like When It's Right
| Component | Implementation |
|---|---|
| Access token | PyJWT, HS256, 15-minute exp |
| Refresh token | secrets.token_urlsafe(32), stored in DB |
| Revocation | DB delete — password change, logout, suspension all use the same call |
| Reuse detection | Mark token used on first refresh; nuke all user tokens on detected reuse |
| Cookie config | httponly=True, secure=True, samesite='Strict' |
The refresh token stops being a JWT entirely in this design. It's an opaque random string that maps to a database record. The record holds the user ID, expiry, and used flag. The JWT-ness of the refresh token bought you nothing because you're doing a database lookup on every refresh anyway.
Access tokens stay JWTs. Stateless verification on every API call — signature check, extract user ID, no database round-trip. That's worth keeping.
The Actual Threat Model
Short-lived access tokens limit damage when:
- The token lives in memory (not localStorage) and the attacker has a narrow XSS window
- The token was leaked in transit and the attacker has to act fast
Short-lived access tokens don't limit damage when:
- Your refresh tokens are stateless and non-revocable — the attacker uses the refresh token instead
- Your refresh tokens are in localStorage — XSS reads both tokens at the same time
The three legs of the system are: short-lived access token, refresh token in httpOnly cookie, refresh token that can be revoked server-side. Most implementations I've reviewed have the first two and skip the third. The breach window on a compromised account is then defined by the refresh token lifetime, not the access token lifetime.
The failure mode isn't a developer who doesn't know about JWT security. It's a developer who sets up the access/refresh split correctly and assumes that's enough — not noticing that the refresh token is still stateless and therefore permanent.