Seguridad de tokens OAuth 2.0: almacenamiento, rotación y revocación
Gestión segura del ciclo de vida de tokens OAuth 2.0: almacenamiento, rotación automática, propagación de revocaciones y protección ante repetición.

Obtener un access token de OAuth 2.0 es la parte fácil. Mantenerlo seguro durante todo su ciclo de vida (almacenamiento, transmisión, rotación y revocación) es donde fallan la mayoría de las aplicaciones. Un token robado es una identidad robada. Cada decisión sobre dónde viven los tokens, cuánto tiempo duran y cómo se renuevan afecta directamente al radio de impacto de una brecha de seguridad.
Los patrones que se describen aquí se aplican tanto si consumes tokens OAuth de un proveedor como si construyes tu propio servidor de autorización.
Almacenamiento de tokens: dónde viven importa
La ubicación de almacenamiento determina qué vectores de ataque pueden robar el token. Cada opción tiene ventajas y desventajas distintas.
// ❌ 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;
}
}Las cookies HTTP-only son invisibles para JavaScript, lo que elimina el robo de tokens mediante XSS. La restricción de path garantiza que el refresh token solo se envíe al endpoint de renovación, no a cada llamada a la API.
Rotación de tokens con refresh tokens
Los tiempos de vida cortos del access token limitan la ventana de explotación si un token es robado. Los refresh tokens te permiten emitir nuevos access tokens sin necesidad de volver a autenticarte.
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";
}
}El seguimiento de la familia de tokens es el mecanismo de seguridad clave. Cuando un refresh token se usa dos veces (algo que ocurre si un atacante copia el token y el usuario legítimo también lo usa), se revoca toda la familia, lo que obliga al usuario real a autenticarse de nuevo pero deja fuera al atacante.
Renovación automática de tokens en el cliente
El cliente debe renovar los tokens expirados de forma transparente, sin interrumpir la experiencia del usuario.
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
}
}La deduplicación con refreshPromise es fundamental. Sin ella, diez llamadas concurrentes a la API que reciban un 401 dispararían diez solicitudes de renovación, invalidando los tokens más rápido de lo que se pueden usar.
Propagación de la revocación de tokens
Cuando un usuario cierra sesión o un evento de seguridad activa la revocación de un token, los tokens activos deben invalidarse en todos los servicios que los aceptan.
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());
}Conclusiones clave
La ubicación de almacenamiento del token determina tu superficie de ataque: usa cookies seguras HTTP-only para aplicaciones web y así eliminar el robo de tokens mediante XSS, y almacenamiento en memoria para las SPA donde las cookies no son viables. Mantén los access tokens de corta duración (15 minutos o menos) y utiliza la rotación de refresh tokens para emitir nuevos pares, reduciendo la ventana de explotación si un token es robado. Implementa el seguimiento de familias de tokens para detectar la reutilización de refresh tokens: cuando se usan tanto un token robado como el token legítimo, revoca toda la familia para dejar fuera al atacante. Deduplica las solicitudes concurrentes de renovación de tokens en el cliente para evitar condiciones de carrera en las que varias respuestas 401 disparen múltiples llamadas de renovación. Mantén una lista de revocación de tokens para invalidarlos de inmediato durante el cierre de sesión o ante eventos de seguridad, y propaga la revocación a todos los servicios que validan tokens. El ciclo de vida seguro de un token no depende de un único mecanismo: es la combinación de vidas cortas, rotación, detección de reutilización y revocación lo que limita el daño cuando (no si) un token se ve comprometido.


