OAuth 2.0 Token Security: Storage, Rotation, and Revocation
Secure OAuth 2.0 token lifecycle management: storage strategies, automatic rotation with refresh tokens, revocation propagation and replay protection.

Getting an OAuth 2.0 access token is the easy part. Keeping it secure throughout its lifecycle—storage, transmission, rotation, and revocation—is where most applications fail. A stolen token is a stolen identity. Every decision about where tokens live, how long they last, and how they're refreshed directly impacts the blast radius of a security breach.
The patterns here apply whether you're consuming OAuth tokens from a provider or building your own authorization server.
Token Storage: Where Tokens Live Matters
The storage location determines which attack vectors can steal the token. Each option has distinct trade-offs.
// ❌ Storing tokens in localStorage — vulnerable to XSS
localStorage.setItem("access_token", token);
// Any script on the page can read this, including
// injected scripts from XSS vulnerabilities// ✅ HTTP-only secure cookies for web applications
import { Response } from "express";
interface TokenCookieOptions {
accessTokenMaxAge: number;
refreshTokenMaxAge: number;
domain: string;
sameSite: "strict" | "lax" | "none";
}
function setTokenCookies(
res: Response,
accessToken: string,
refreshToken: string,
options: TokenCookieOptions
): void {
// Access token: short-lived, HTTP-only
res.cookie("access_token", accessToken, {
httpOnly: true,
secure: true,
sameSite: options.sameSite,
domain: options.domain,
maxAge: options.accessTokenMaxAge,
path: "/api",
});
// Refresh token: longer-lived, restricted path
res.cookie("refresh_token", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
domain: options.domain,
maxAge: options.refreshTokenMaxAge,
path: "/api/auth/refresh",
});
}
// For SPAs that can't use cookies (mobile, cross-origin):
// Use in-memory storage with a service worker
class SecureTokenStore {
private accessToken: string | null = null;
private refreshToken: string | null = null;
setTokens(access: string, refresh: string): void {
this.accessToken = access;
this.refreshToken = refresh;
// Do NOT persist to localStorage or sessionStorage
}
getAccessToken(): string | null {
return this.accessToken;
}
getRefreshToken(): string | null {
return this.refreshToken;
}
clear(): void {
this.accessToken = null;
this.refreshToken = null;
}
}HTTP-only cookies are invisible to JavaScript, which eliminates XSS token theft. The path restriction ensures the refresh token is only sent to the refresh endpoint, not to every API call.
Token Rotation with Refresh Tokens
Short access token lifetimes limit the window of exploitation if a token is stolen. Refresh tokens let you issue new access tokens without re-authentication.
interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
interface RefreshTokenRecord {
token: string;
userId: string;
family: string; // Token family for rotation detection
used: boolean;
createdAt: Date;
expiresAt: Date;
}
class TokenRotationService {
private refreshTokens: Map<string, RefreshTokenRecord> = new Map();
async issueTokenPair(userId: string): Promise<TokenPair> {
const family = this.generateFamily();
return this.createPair(userId, family);
}
async refresh(refreshToken: string): Promise<TokenPair> {
const record = this.refreshTokens.get(refreshToken);
if (!record) {
throw new SecurityError("Invalid refresh token");
}
if (record.expiresAt < new Date()) {
this.refreshTokens.delete(refreshToken);
throw new SecurityError("Refresh token expired");
}
// CRITICAL: Detect token reuse
if (record.used) {
// This refresh token was already used — possible theft
// Revoke the entire token family
await this.revokeFamily(record.family);
throw new SecurityError(
"Refresh token reuse detected — all sessions revoked"
);
}
// Mark current token as used
record.used = true;
// Issue new pair with same family
return this.createPair(record.userId, record.family);
}
private createPair(
userId: string,
family: string
): TokenPair {
const accessToken = this.generateAccessToken(userId);
const refreshToken = this.generateRefreshToken();
this.refreshTokens.set(refreshToken, {
token: refreshToken,
userId,
family,
used: false,
createdAt: new Date(),
expiresAt: new Date(
Date.now() + 7 * 24 * 60 * 60 * 1000
),
});
return {
accessToken,
refreshToken,
expiresIn: 900, // 15 minutes
};
}
private async revokeFamily(family: string): Promise<void> {
for (const [token, record] of this.refreshTokens) {
if (record.family === family) {
this.refreshTokens.delete(token);
}
}
}
private generateFamily(): string {
return crypto.randomUUID();
}
private generateAccessToken(userId: string): string {
// Sign with short expiry
return `at_${userId}_${Date.now()}`;
}
private generateRefreshToken(): string {
return `rt_${crypto.randomUUID()}`;
}
}
class SecurityError extends Error {
constructor(message: string) {
super(message);
this.name = "SecurityError";
}
}Token family tracking is the key security mechanism. When a refresh token is used twice—which happens if an attacker copies the token and the legitimate user also uses it—the entire family is revoked, forcing the real user to re-authenticate but cutting off the attacker.
Automatic Token Refresh in the Client
The client must transparently refresh expired tokens without interrupting the user experience.
class AuthenticatedClient {
private tokenStore: SecureTokenStore;
private refreshPromise: Promise<void> | null = null;
constructor(
private baseUrl: string,
tokenStore: SecureTokenStore
) {
this.tokenStore = tokenStore;
}
async request(
path: string,
options: RequestInit = {}
): Promise<Response> {
const token = this.tokenStore.getAccessToken();
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
...options.headers,
Authorization: token ? `Bearer ${token}` : "",
},
});
// If 401, try refreshing and retrying once
if (response.status === 401) {
await this.ensureRefreshed();
const newToken = this.tokenStore.getAccessToken();
if (!newToken) {
// Refresh failed — redirect to login
this.handleSessionExpired();
throw new Error("Session expired");
}
return fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${newToken}`,
},
});
}
return response;
}
private async ensureRefreshed(): Promise<void> {
// Deduplicate concurrent refresh attempts
if (!this.refreshPromise) {
this.refreshPromise = this.doRefresh();
try {
await this.refreshPromise;
} finally {
this.refreshPromise = null;
}
} else {
await this.refreshPromise;
}
}
private async doRefresh(): Promise<void> {
const refreshToken = this.tokenStore.getRefreshToken();
if (!refreshToken) return;
const response = await fetch(
`${this.baseUrl}/api/auth/refresh`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
}
);
if (response.ok) {
const data = await response.json();
this.tokenStore.setTokens(
data.accessToken,
data.refreshToken
);
} else {
this.tokenStore.clear();
}
}
private handleSessionExpired(): void {
this.tokenStore.clear();
// Redirect to login
}
}The deduplication with refreshPromise is critical. Without it, ten concurrent API calls hitting 401 would trigger ten refresh requests, potentially invalidating tokens faster than they can be used.
Token Revocation Propagation
When a user logs out or a security event triggers token revocation, active tokens must be invalidated across all services that accept them.
interface RevocationEntry {
tokenId: string;
revokedAt: Date;
reason: string;
expiresAt: Date; // Clean up after token would have expired
}
class TokenRevocationList {
private revoked: Map<string, RevocationEntry> = new Map();
revoke(tokenId: string, reason: string, ttl: number): void {
this.revoked.set(tokenId, {
tokenId,
revokedAt: new Date(),
reason,
expiresAt: new Date(Date.now() + ttl * 1000),
});
}
isRevoked(tokenId: string): boolean {
return this.revoked.has(tokenId);
}
cleanup(): void {
const now = new Date();
for (const [id, entry] of this.revoked) {
if (entry.expiresAt < now) {
this.revoked.delete(id);
}
}
}
}
// Middleware that checks revocation before processing
function revocationCheckMiddleware(
revocationList: TokenRevocationList
) {
return (req: Request, res: Response, next: NextFunction) => {
const token = extractToken(req);
if (!token) {
return res.status(401).json({ error: "No token" });
}
const decoded = decodeToken(token);
if (revocationList.isRevoked(decoded.jti)) {
return res.status(401).json({ error: "Token revoked" });
}
next();
};
}
function extractToken(req: any): string | null {
const auth = req.headers.authorization;
if (auth?.startsWith("Bearer ")) {
return auth.slice(7);
}
return req.cookies?.access_token ?? null;
}
function decodeToken(token: string): { jti: string; sub: string } {
// Decode JWT payload (verification happens elsewhere)
const payload = token.split(".")[1];
return JSON.parse(Buffer.from(payload, "base64url").toString());
}Key Takeaways
Token storage location determines your attack surface—use HTTP-only secure cookies for web applications to eliminate XSS token theft, and in-memory storage for SPAs where cookies aren't viable. Keep access tokens short-lived (15 minutes or less) and use refresh token rotation to issue new pairs, reducing the window of exploitation if a token is stolen. Implement token family tracking to detect refresh token reuse—when a stolen token and the legitimate token are both used, revoke the entire family to cut off the attacker. Deduplicate concurrent token refresh requests on the client to prevent race conditions where multiple 401 responses trigger multiple refresh calls. Maintain a token revocation list for immediate invalidation during logout or security events, and propagate revocation across all services that validate tokens. The secure token lifecycle isn't about any single mechanism—it's the combination of short lifetimes, rotation, reuse detection, and revocation that limits the damage when (not if) a token is compromised.


