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.
{“@context”:”https://schema.org”,”@type”:”FAQPage”,”mainEntity”:[{“@type”:”Question”,”name”:”Why should access tokens be short-lived and refresh tokens be long-lived?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Access tokens are sent with every API request and are validated by the server via signature verification alone — no database lookup. If a token is stolen (from a log, a compromised client, a man-in-the-middle), the attacker can use it until it expires. A 15-minute expiry limits the damage window to 15 minutes. Refresh tokens, by contrast, are only sent to the auth server’s token endpoint. They are stored server-side (hashed) and can be instantly revoked. Long-lived refresh tokens (30 days) provide a good user experience — users don’t need to re-enter passwords frequently. Short-lived access tokens provide security. The combination gives both: good UX and revocability at the refresh layer.”}},{“@type”:”Question”,”name”:”What is refresh token rotation and why is it important?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Refresh token rotation means each use of a refresh token issues a new refresh token and immediately revokes the old one. The old token becomes single-use. If an attacker steals a refresh token and the legitimate user refreshes first, the attacker’s subsequent use of the old token is detected (the token is already revoked), and the system knows a theft occurred. Without rotation, a stolen refresh token can be used indefinitely until it expires (30 days). With rotation, the theft detection window is bounded by how quickly the legitimate user makes their next refresh call. Rotation also means a stolen token from a network log becomes worthless as soon as the real client makes any API request.”}},{“@type”:”Question”,”name”:”How does refresh token family-based theft detection work?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Each login issues a refresh token that belongs to a "family" (identified by a family_id UUID). Every rotation creates a new token in the same family — the family_id is inherited. If a revoked token is presented: the attacker has used a token that was already rotated away. The system detects this because the token exists in the DB (is_revoked=true). At this point, the system revokes ALL active tokens in the same family — forcing the legitimate user to re-authenticate. This is aggressive but necessary: the system cannot tell if the legitimate user or the attacker rotated the token last. Revoking the family ensures at most one party keeps access. The legitimate user must log in again; the attacker loses all tokens in that family.”}},{“@type”:”Question”,”name”:”How do you handle two simultaneous refresh requests for the same token?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”This happens on mobile apps that refresh on app foregrounding — if the user switches between two devices simultaneously, both send the refresh token at once. The first request succeeds and rotates the token. The second request presents the now-revoked old token — this triggers theft detection and revokes the entire family, logging the user out of both devices. To prevent this false positive: (1) Implement a short grace period — if the token was rotated less than 5 seconds ago, the old token is still accepted once and the new token is returned. (2) Use pessimistic locking on the refresh operation: SELECT … FOR UPDATE on the token row, so only one refresh can proceed at a time. The second concurrent request waits, then fails with "token already rotated" and the client retries with the new token.”}},{“@type”:”Question”,”name”:”How do you revoke all tokens after a password change or account compromise?”,”acceptedAnswer”:{“@type”:”Answer”,”text”:”Store tokens with a reference to the user. On password change or security event: UPDATE RefreshToken SET revoked_at=NOW() WHERE user_id=%s AND revoked_at IS NULL. This revokes all active refresh tokens across all devices. Active access tokens (JWTs) cannot be revoked mid-lifetime — they are stateless and expire on their own (max 15 minutes). If immediate access token revocation is required (account compromise), maintain a token blocklist: a Redis SET of revoked JTI (JWT ID) values with TTL equal to the access token lifetime. The token validation path checks: is this JTI in the blocklist? If yes, reject. This adds one Redis lookup per request — acceptable for security-critical paths, overhead for normal operations. Only use a blocklist when 15-minute token expiry is too long.”}}]}
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.