OAuth 2.0 y OpenID Connect: guía práctica
Implementa bien OAuth 2.0 y OpenID Connect: flujo de código con PKCE, gestión de tokens, rotación de refresh y los fallos que causan robo de cuentas.

OAuth 2.0 es autorización, no autenticación
OAuth 2.0 responde a la pregunta «¿qué puede acceder esta aplicación?», no a «¿quién es este usuario?». OpenID Connect (OIDC) añade una capa de autenticación sobre OAuth y aporta tokens de identidad que indican quién es el usuario. Confundir ambos conceptos genera vulnerabilidades de seguridad, como usar un access token pensado para una API como si fuera prueba de identidad en otra.
Flujo de código de autorización con PKCE
El flujo de código de autorización con PKCE (Proof Key for Code Exchange) es el flujo recomendado para todo tipo de cliente: aplicaciones web, aplicaciones móviles y SPA. Evita los ataques de interceptación del código de autorización.
import { randomBytes, createHash } from "node:crypto";
// Step 1: Generate PKCE challenge
function generatePKCE(): { verifier: string; challenge: string } {
const verifier = randomBytes(32)
.toString("base64url")
.slice(0, 128);
const challenge = createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
// Step 2: Build authorization URL
function buildAuthorizationURL(config: OAuthConfig): {
url: string;
state: string;
verifier: string;
} {
const { verifier, challenge } = generatePKCE();
const state = randomBytes(16).toString("hex");
const params = new URLSearchParams({
response_type: "code",
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: "openid profile email",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
return {
url: `${config.authorizationEndpoint}?${params}`,
state,
verifier,
};
}// ❌ Implicit flow — tokens exposed in URL fragment
// DEPRECATED: access_token appears in browser history and referrer headers
const badUrl = `${authEndpoint}?response_type=token&client_id=${clientId}`;
// ✅ Authorization Code + PKCE — tokens never in URLs
const { url, state, verifier } = buildAuthorizationURL(config);
// Store state and verifier in session, redirect user to urlIntercambio de tokens
Una vez que el usuario autoriza el acceso, el servidor de autorización redirige de vuelta con un código. Ese código se intercambia por tokens desde el servidor.
async function exchangeCodeForTokens(
code: string,
verifier: string,
config: OAuthConfig
): Promise<TokenResponse> {
const response = await fetch(config.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
code_verifier: verifier,
}),
});
if (!response.ok) {
const error = await response.json();
throw new AuthError(
`Token exchange failed: ${error.error_description ?? error.error}`
);
}
const tokens: TokenResponse = await response.json();
// Validate the ID token
if (tokens.id_token) {
await validateIdToken(tokens.id_token, config);
}
return tokens;
}
interface TokenResponse {
access_token: string;
token_type: "Bearer";
expires_in: number;
refresh_token?: string;
id_token?: string;
scope: string;
}Validación del ID token
El ID token es un JWT que contiene las claims de identidad. Hay que validarlo a fondo: firma, emisor (issuer), audiencia (audience), expiración y nonce.
import { jwtVerify, createRemoteJWKSet } from "jose";
async function validateIdToken(
idToken: string,
config: OAuthConfig
): Promise<UserInfo> {
const jwks = createRemoteJWKSet(
new URL(config.jwksUri)
);
const { payload } = await jwtVerify(idToken, jwks, {
issuer: config.issuer,
audience: config.clientId,
maxTokenAge: "5m",
clockTolerance: "30s",
});
// Verify required claims exist
if (!payload.sub) {
throw new AuthError("ID token missing sub claim");
}
return {
id: payload.sub,
email: payload.email as string | undefined,
name: payload.name as string | undefined,
emailVerified: payload.email_verified as boolean | undefined,
};
}
interface UserInfo {
id: string;
email?: string;
name?: string;
emailVerified?: boolean;
}Rotación de refresh tokens
Los access tokens tienen una vida corta (minutos). Los refresh tokens permiten obtener nuevos access tokens sin que el usuario intervenga. Es fundamental rotar los refresh tokens en cada uso: cada refresh token es de un solo uso, y reutilizar uno antiguo invalida toda la sesión.
class TokenManager {
private refreshTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
private readonly config: OAuthConfig,
private readonly storage: TokenStorage
) {}
async refreshAccessToken(): Promise<string> {
const refreshToken = await this.storage.getRefreshToken();
if (!refreshToken) {
throw new AuthError("No refresh token available — re-authentication required");
}
const response = await fetch(this.config.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}),
});
if (!response.ok) {
// Refresh token was revoked or expired
await this.storage.clearTokens();
throw new AuthError("Session expired — please log in again");
}
const tokens: TokenResponse = await response.json();
// Store the NEW refresh token (rotation)
await this.storage.setAccessToken(tokens.access_token, tokens.expires_in);
if (tokens.refresh_token) {
await this.storage.setRefreshToken(tokens.refresh_token);
}
this.scheduleRefresh(tokens.expires_in);
return tokens.access_token;
}
private scheduleRefresh(expiresInSeconds: number): void {
if (this.refreshTimer) clearTimeout(this.refreshTimer);
// Refresh 60 seconds before expiration
const refreshInMs = (expiresInSeconds - 60) * 1000;
this.refreshTimer = setTimeout(
() => this.refreshAccessToken(),
Math.max(refreshInMs, 0)
);
}
}Almacenamiento seguro de tokens
El lugar donde se almacenan los tokens importa. En aplicaciones web, cookies HttpOnly. En móvil, APIs de almacenamiento seguro. Nunca localStorage para tokens sensibles.
// ❌ localStorage — accessible to any JavaScript on the page (XSS risk)
localStorage.setItem("access_token", tokens.access_token);
// ✅ HttpOnly, Secure, SameSite cookies — inaccessible to JavaScript
function setTokenCookie(
res: Response,
name: string,
value: string,
maxAge: number
): void {
res.setHeader("Set-Cookie", [
`${name}=${value}`,
"HttpOnly",
"Secure",
"SameSite=Lax",
`Max-Age=${maxAge}`,
"Path=/",
].join("; "));
}
// Set tokens as HttpOnly cookies after exchange
function handleCallback(req: Request, res: Response): void {
// ... exchange code for tokens ...
setTokenCookie(res, "access_token", tokens.access_token, tokens.expires_in);
if (tokens.refresh_token) {
setTokenCookie(res, "refresh_token", tokens.refresh_token, 30 * 24 * 3600);
}
res.redirect("/dashboard");
}Errores de seguridad más comunes
Estos errores aparecen con frecuencia en implementaciones de OAuth en producción y suelen derivar en el robo de cuentas.
interface SecurityChecklist {
check: string;
risk: string;
mitigation: string;
}
const oauthChecklist: SecurityChecklist[] = [
{
check: "State parameter validation",
risk: "CSRF attack — attacker forces victim to link their account",
mitigation: "Generate random state, store in session, validate on callback",
},
{
check: "PKCE on all flows",
risk: "Authorization code interception on public clients",
mitigation: "Always use S256 code challenge, even for confidential clients",
},
{
check: "Redirect URI exact match",
risk: "Open redirect allows token theft via crafted URLs",
mitigation: "Register exact redirect URIs, never wildcard or partial match",
},
{
check: "ID token audience validation",
risk: "Token confusion — token from another app used as identity proof",
mitigation: "Verify aud claim matches your client_id",
},
{
check: "Refresh token rotation",
risk: "Stolen refresh token grants indefinite access",
mitigation: "Rotate on use, detect reuse as compromise signal",
},
];Puntos clave
Usa el flujo de código de autorización con PKCE para todos los clientes: web, móvil y SPA. Nunca uses el flujo Implicit; expone los tokens en las URL. Valida los ID tokens a fondo: firma, emisor, audiencia y expiración. Almacena los tokens en cookies HttpOnly para aplicaciones web, nunca en localStorage.
Rota los refresh tokens en cada uso para que un token robado solo sirva una vez. Valida el parámetro state en cada callback para prevenir ataques CSRF. Registra las redirect URI de forma exacta: una coincidencia parcial habilita ataques de open redirect. OAuth y OIDC son protocolos complejos con muchos detalles delicados, pero implementarlos correctamente no es negociable en ninguna aplicación que maneje la identidad de los usuarios.


