Saltar al contenido

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.

5 min de lectura
Diagrama del ciclo de vida de un token que muestra la emisión, el almacenamiento, la rotación mediante refresh tokens y los flujos de revocación con límites de seguridad en cada etapa

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.

tstypescript
// ❌ 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
tstypescript
// ✅ 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.

tstypescript
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.

tstypescript
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.

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX