Token refresh is the mechanism by which a client exchanges a long-lived refresh token for a new short-lived access token, without requiring the user to re-authenticate. Access tokens expire quickly (15 minutes to 1 hour) to limit the damage from token theft. Refresh tokens last days or months but are stored securely server-side. The refresh flow is where most authentication bugs live: improper rotation, missing revocation, and replay attacks on stolen refresh tokens.
Core Data Model
CREATE TABLE RefreshToken (
token_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id BIGINT NOT NULL REFERENCES User(id),
token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 of the actual token
family_id UUID NOT NULL, -- rotation family (for theft detection)
device_info JSONB, -- user agent, IP, device fingerprint
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
replaced_by UUID REFERENCES RefreshToken(token_id)
);
CREATE INDEX idx_rt_user ON RefreshToken(user_id, revoked_at)
WHERE revoked_at IS NULL;
CREATE INDEX idx_rt_family ON RefreshToken(family_id);
Issue Tokens on Login
import secrets
import hashlib
import jwt
from datetime import datetime, timedelta, timezone
def issue_tokens(user_id: int, device_info: dict) -> dict:
# Generate refresh token: 32 random bytes, hex-encoded (64 chars)
refresh_token_raw = secrets.token_hex(32)
refresh_token_hash = hashlib.sha256(refresh_token_raw.encode()).hexdigest()
family_id = str(uuid4())
expires_at = datetime.now(timezone.utc) + timedelta(days=30)
db.execute("""
INSERT INTO RefreshToken (user_id, token_hash, family_id, device_info, expires_at)
VALUES (%s, %s, %s, %s, %s)
""", [user_id, refresh_token_hash, family_id, json.dumps(device_info), expires_at])
# Access token: short-lived JWT (15 minutes)
access_token = jwt.encode({
'sub': str(user_id),
'iat': datetime.now(timezone.utc),
'exp': datetime.now(timezone.utc) + timedelta(minutes=15),
'jti': str(uuid4()) # unique ID for this access token
}, JWT_SECRET, algorithm='HS256')
return {
'access_token': access_token,
'refresh_token': refresh_token_raw, # raw token sent to client
'expires_in': 900 # 15 minutes
}
Refresh Flow with Rotation and Theft Detection
def refresh_access_token(refresh_token_raw: str, device_info: dict) -> dict:
token_hash = hashlib.sha256(refresh_token_raw.encode()).hexdigest()
# Look up the token
token_row = db.fetchone("""
SELECT * FROM RefreshToken
WHERE token_hash = %s
""", [token_hash])
if not token_row:
raise InvalidTokenError("Token not found")
# Check if this token was already used (revoked and replaced)
if token_row['revoked_at'] is not None:
# THEFT DETECTION: a revoked token is being used
# The family may have been compromised — revoke the entire family
db.execute("""
UPDATE RefreshToken
SET revoked_at = NOW()
WHERE family_id = %s AND revoked_at IS NULL
""", [token_row['family_id']])
raise TokenReuseError(
"Refresh token already used — possible theft detected. "
"All sessions in this family revoked."
)
if datetime.now(timezone.utc) > token_row['expires_at']:
raise TokenExpiredError("Refresh token expired")
# TOKEN ROTATION: revoke the current token and issue a new one
# New token inherits the same family_id (for future theft detection)
new_refresh_raw = secrets.token_hex(32)
new_refresh_hash = hashlib.sha256(new_refresh_raw.encode()).hexdigest()
new_expires = datetime.now(timezone.utc) + timedelta(days=30)
with db.transaction():
# Revoke old token
new_token_id = str(uuid4())
db.execute("""
UPDATE RefreshToken
SET revoked_at = NOW(), replaced_by = %s
WHERE token_id = %s
""", [new_token_id, token_row['token_id']])
# Insert new token in the same family
db.execute("""
INSERT INTO RefreshToken (token_id, user_id, token_hash, family_id,
device_info, expires_at)
VALUES (%s, %s, %s, %s, %s, %s)
""", [new_token_id, token_row['user_id'], new_refresh_hash,
token_row['family_id'], json.dumps(device_info), new_expires])
# Issue new access token
access_token = jwt.encode({
'sub': str(token_row['user_id']),
'exp': datetime.now(timezone.utc) + timedelta(minutes=15),
'jti': str(uuid4())
}, JWT_SECRET, algorithm='HS256')
return {
'access_token': access_token,
'refresh_token': new_refresh_raw,
'expires_in': 900
}
Logout and Revocation
def logout(refresh_token_raw: str):
"""Revoke this device's session only."""
token_hash = hashlib.sha256(refresh_token_raw.encode()).hexdigest()
db.execute("""
UPDATE RefreshToken SET revoked_at = NOW()
WHERE token_hash = %s AND revoked_at IS NULL
""", [token_hash])
def logout_all_devices(user_id: int):
"""Revoke all sessions for this user (e.g., after password change)."""
db.execute("""
UPDATE RefreshToken SET revoked_at = NOW()
WHERE user_id = %s AND revoked_at IS NULL
""", [user_id])
def validate_access_token(token: str) -> dict:
"""Validate a JWT access token. No DB lookup needed."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
return payload
except jwt.ExpiredSignatureError:
raise TokenExpiredError("Access token expired")
except jwt.InvalidTokenError:
raise InvalidTokenError("Invalid access token")
Key Interview Points
- Store the hash of the refresh token, never the raw value — the database is a high-value target. If leaked, hashed tokens cannot be used directly.
- Refresh token rotation: every refresh issues a new token and revokes the old one. This limits the reuse window to near-zero.
- Refresh token families enable theft detection: if a revoked token is presented (attacker using a stolen token), revoke the entire family — all active sessions in that family are terminated, forcing re-authentication.
- Access tokens are stateless JWTs — validation requires no DB lookup (just signature verification). This is why access tokens must be short-lived: they cannot be revoked mid-lifetime without a token blocklist (DB overhead).
- Concurrent refresh race: if a mobile app sends two simultaneous requests and both try to refresh the same token, one succeeds and one fails with “token already used.” Clients must handle this with locking or retry logic.
- After password change or account compromise: call logout_all_devices to invalidate all refresh tokens. Active access tokens remain valid until expiry (max 15 minutes) — this is the inherent tradeoff of stateless JWT access tokens.
OAuth token refresh and authentication system design is discussed in Google system design interview questions.
Token refresh and secure authentication design is covered in Coinbase system design interview preparation.
OAuth token refresh and API authentication design is discussed in Stripe system design interview guide.