Definition
- Access token – a short-lived credential (typically 15 minutes) presented on every API call to prove authorization; kept small and self-contained (JWT) so services validate it statelessly.
- Refresh token – a long-lived credential (days or weeks) used only to obtain a fresh access token from the auth server; stored server-side in hashed form and tightly guarded.
- Refresh token rotation – each time a refresh token is used, the server issues a new one and invalidates the old one, making it single-use.
- Reuse detection – if an already-used refresh token is ever presented again, that signals theft; the server revokes the entire token family and forces re-login.
The access token is like a visitor pass at National Bank of India that lets Rajesh move around for only fifteen minutes before it must be renewed. The refresh token is like the master membership card he keeps safely in his wallet; when his short pass expires he shows the membership card at the desk and gets a fresh fifteen-minute pass without signing in again. With rotation, every time he uses the membership card the desk shreds it and hands him a new one, so if a thief copied his old card, the moment either the thief or Rajesh uses it, the bank notices two cards claiming to be the same and locks the account.
Understanding the Concepts
What is an access token?
An access token is a short-lived credential sent on each API request as Authorization: Bearer .... It is validated statelessly by checking the signature and expiry. Keeping it short-lived (about 15 minutes) limits the damage window if it leaks.
Because it is used constantly, it should not require a database lookup, which is why it stays a self-contained JWT.
package com.bank.security;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Date;
public class AccessTokenService {
private static final SecretKey KEY = Keys.hmacShaKeyFor(
"national-bank-secret-key-256-bits-long!!!".getBytes());
private static final long ACCESS_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes
public String issueAccessToken(String username, String role) {
return Jwts.builder()
.subject(username)
.claim("role", role)
.claim("type", "access")
.issuer("national-bank")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + ACCESS_EXPIRY_MS))
.signWith(KEY)
.compact();
}
public static void main(String[] args) {
AccessTokenService service = new AccessTokenService();
String token = service.issueAccessToken("rajesh", "CUSTOMER");
System.out.println("Access token (15 min): " + token.substring(0, 30) + "...");
}
}
Access token (15 min): eyJhbGciOiJIUzI1NiJ9.eyJzd...
What is a refresh token?
A refresh token is a long-lived credential (days or weeks) whose only purpose is to obtain a new access token without forcing the user to log in again. It is used rarely: only when the access token expires.
Unlike the access token, the refresh token is stored server-side in hashed form so it can be revoked. In the browser it belongs in an HttpOnly, Secure, SameSite cookie, never in localStorage.
package com.bank.security;
import java.security.SecureRandom;
import java.util.Base64;
import java.security.MessageDigest;
public class RefreshTokenService {
private static final SecureRandom RANDOM = new SecureRandom();
public String generateRefreshToken() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
public String hashForStorage(String token) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(token.getBytes());
return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
}
public static void main(String[] args) throws Exception {
RefreshTokenService service = new RefreshTokenService();
String token = service.generateRefreshToken();
String hash = service.hashForStorage(token);
System.out.println("Refresh token: " + token);
System.out.println("Stored hash: " + hash);
}
}
Refresh token: 7Kx2mP9qR3tUvWxYz1bCdEfGhIjKlMnO
Stored hash: sha256_hash_of_token_stored_in_db
How is the access token different from the refresh token?
| Aspect | Access Token | Refresh Token |
|---|---|---|
| Lifetime | Short: about 15 minutes | Long: days to weeks |
| Usage frequency | Every API call | Only when access token expires |
| Format | Self-contained JWT | Opaque string (server-tracked) |
| Validation | Stateless: signature + expiry | Stateful: server-side lookup |
| Storage (browser) | Memory or HttpOnly cookie | HttpOnly, Secure, SameSite cookie |
| Revocation | Hard (valid until expiry) | Easy (delete from server store) |
| Purpose | Prove authorization on API calls | Obtain a new access token silently |
package com.bank.security;
// Access token: stateless validation, every request
public class AccessValidation {
public String validate(String jwt) {
return Jwts.parser().verifyWith(key).build()
.parseSignedClaims(jwt).getPayload().getSubject();
// No database call.
}
}
// Refresh token: stateful validation, rare
public class RefreshValidation {
public boolean validate(String token) {
String hash = sha256(token);
// Database lookup to check token exists and is not used
return refreshTokenRepository.findByHash(hash).isPresent();
}
}
What is refresh token rotation?
Refresh token rotation means each time a refresh token is used, the server issues a brand-new refresh token and invalidates the old one. A given refresh token works at most once.
This turns a stolen refresh token into a single-use, quickly-detected incident rather than a long-lived master key.
package com.bank.security;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
public class TokenRotationService {
// Simulates a database: tokenHash -> {familyId, used}
private final Map<String, TokenRecord> store = new ConcurrentHashMap<>();
public TokenPair refresh(String oldRefreshToken) {
String hash = sha256(oldRefreshToken);
TokenRecord record = store.get(hash);
if (record == null) {
throw new SecurityException("Unknown refresh token.");
}
if (record.used) {
// Reuse detected: revoke entire family!
revokeFamily(record.familyId);
throw new SecurityException("Refresh token reuse detected. "
+ "Family revoked. Re-login required.");
}
// Mark old token as used
record.used = true;
// Issue new tokens
String newAccessToken = issueAccessToken(record.subject);
String newRefreshToken = generateRefreshToken();
store.put(sha256(newRefreshToken),
new TokenRecord(record.familyId, record.subject, false));
System.out.println("Rotation: old token marked used, new token issued.");
return new TokenPair(newAccessToken, newRefreshToken);
}
private void revokeFamily(String familyId) {
store.values().removeIf(r -> r.familyId.equals(familyId));
System.out.println("Family " + familyId + " revoked.");
}
// Helper classes and methods omitted for brevity
static class TokenRecord {
String familyId, subject;
boolean used;
TokenRecord(String fid, String sub, boolean u) {
familyId = fid; subject = sub; used = u;
}
}
static class TokenPair {
String accessToken, refreshToken;
TokenPair(String a, String r) { accessToken = a; refreshToken = r; }
}
private String sha256(String s) { return s; } // simplified
private String issueAccessToken(String s) { return "jwt_" + s; }
private String generateRefreshToken() { return UUID.randomUUID().toString(); }
}
Normal refresh:
Rotation: old token marked used, new token issued.
Thief replays the old token:
Family abc-123 revoked.
SecurityException: Refresh token reuse detected. Family revoked.
What is reuse detection?
Reuse detection catches stolen refresh tokens. The server stores each refresh token with a familyId (a group identifier for all tokens in a chain) and a used flag.
When an already-used refresh token is presented, that is a strong signal of theft: either the attacker or the legitimate user has the old token, and the other has the new one. The server revokes the entire family, killing even the legitimate new token, and forces a fresh login.
Normal flow:
Token A (unused) --> rotate --> Token B (unused)
Token B (unused) --> rotate --> Token C (unused)
Theft scenario:
Token A (unused) --> rotate --> Token B (unused) [sent to legitimate user]
Thief steals Token A --> presents Token A (USED!) --> reuse detected!
Server revokes family: Token B also killed.
Both attacker and user must re-login.
What are common mistakes?
The first mistake is storing the refresh token in localStorage. Because localStorage is accessible to any JavaScript on the page, a single XSS vulnerability lets an attacker steal the long-lived refresh token and silently renew access indefinitely.
// WRONG: refresh token in localStorage (accessible to XSS)
// JavaScript: localStorage.setItem('refreshToken', token);
// CORRECT: refresh token in HttpOnly, Secure, SameSite cookie
// Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh
The second mistake is issuing refresh tokens without rotation, so a stolen refresh token works indefinitely until its natural expiry.
Where should tokens be stored?
The access token should be kept in memory (a JavaScript variable) for SPAs, or in an HttpOnly cookie for server-rendered apps. The refresh token must always be in an HttpOnly, Secure, SameSite cookie, scoped to the refresh endpoint path.
package com.bank.controller;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TokenCookieController {
@PostMapping("/auth/login")
public String login(HttpServletResponse response) {
String accessToken = "eyJhbGciOi..."; // JWT
String refreshToken = "7Kx2mP9qR3t..."; // Opaque
// Refresh token in HttpOnly cookie
Cookie cookie = new Cookie("refresh_token", refreshToken);
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setPath("/auth/refresh"); // scoped to refresh endpoint
cookie.setMaxAge(7 * 24 * 60 * 60); // 7 days
response.addCookie(cookie);
// Access token returned in response body (stored in memory by SPA)
return "{\"accessToken\":\"" + accessToken + "\"}";
}
}
What should a senior engineer know?
Senior engineers implement token families with a database table: refresh_tokens(hash, family_id, subject, used, created_at, expires_at). The hash prevents the token from being usable if the database is compromised. The family_id links all tokens in a rotation chain for batch revocation.
Senior engineers also know that refresh tokens should have absolute expiry (even with rotation, the family expires after a maximum lifetime) and that the refresh endpoint itself should be rate-limited to prevent brute-force attacks on stolen tokens.
Visual Overview
Token Lifecycle:
┌────────┐ login ┌─────────────┐
│ Client ├──────────►│ Auth Server │
│(Rajesh)│ │ │
│ │◄──────────┤ Issues: │
│ │ │ Access (15m)│
│ │ │ Refresh (7d)│
└───┬────┘ └─────────────┘
│
│ Access token on every API call
▼
┌──────────────┐
│Resource Server│ validates signature + expiry
└──────────────┘
When access token expires:
┌────────┐ refresh ┌─────────────┐
│ Client ├────────────►│ Auth Server │
│ │ old RT │ │
│ │ │ Validates │
│ │◄────────────┤ Rotates: │
│ │ new AT + │ Old RT used │
│ │ new RT │ New RT out │
└────────┘ └─────────────┘
Refresh Token Rotation + Reuse Detection:
Normal: RT-1 ──► rotate ──► RT-2 ──► rotate ──► RT-3
(used) (used) (active)
Theft: RT-1 ──► rotate ──► RT-2 (sent to user)
│
└──► Thief replays RT-1 (already used!)
│
▼
REUSE DETECTED!
Revoke entire family.
RT-2 also killed.
Force re-login.
Key Takeaway
An access token is a short-lived credential, typically about fifteen minutes, that the client sends on every API call to prove authorization, and it is kept small and self-contained so services can validate it statelessly. A refresh token is a long-lived credential, often days or weeks, whose only job is to obtain a new access token from the auth server without forcing the user to log in again. The split gives both safety and convenience: the constantly-used token has a tiny leak window, while the rarely-used token keeps the user signed in.
In the National Bank of India mobile app, the access token is sent on every request and, when it expires, the app quietly exchanges the refresh token for a new access token in the background, so Rajesh never sees a login screen during the day. The access token is a JWT validated by signature and expiry, while the refresh token is stored server-side in hashed form so it can be revoked, and in the browser it lives in an HttpOnly, Secure, SameSite cookie rather than localStorage.
Refresh token rotation is the part that matters most. Every time a refresh token is used, the server issues a brand-new refresh token and marks the old one used, so a given refresh token works at most once. If an already-used refresh token is ever presented again, that is a strong signal of theft, so the server revokes the entire token family and forces a fresh login.
The design turns a stolen refresh token from a long-lived master key into a single-use, quickly-detected incident, which is exactly the guarantee a bank needs.
Remember one sentence: short-lived access tokens limit the leak window, long-lived refresh tokens keep the session alive, and rotation ensures a stolen refresh token works at most once.
---