Command Palette

Search for a command to run...

ES·EN

Nivel 3 · 30 min

OAuth 2.0 + PKCE: Auth Bien Hecha

OAuth 2.0 es un framework de delegación: un usuario le da permiso a una app cliente para acceder a un recurso protegido sin compartir su contraseña. Bien hecho, es el patrón de autenticación más fuerte disponible para la mayoría de los equipos. Mal hecho, conseguís account takeovers, tokens filtrados y findings de auditoría. Authorization Code + PKCE es el único flow que deberías usar en 2026 para clientes nuevos.

El Authorization Code Flow

Pasos: (1) el cliente redirige el browser al servidor de autorización (ej: accounts.google.com) con response_type=code, client_id, redirect_uri, scope, state. (2) El usuario se autentica y consiente en el AS. (3) El AS redirige el browser de vuelta al redirect_uri con ?code=XYZ&state=.... (4) El servidor del cliente intercambia el code por tokens en el endpoint /token del AS, mandando el code, client_id, client_secret (para clientes confidenciales), y redirect_uri. (5) El AS devuelve access_token, refresh_token, id_token. Los flows deprecados: Implicit (response_type=token — el token viene en el fragmento de URL, fácil de filtrar) y Resource Owner Password Credentials (el cliente manda username/password directamente al AS — anula todo el sentido de OAuth). Los dos están removidos en OAuth 2.1.

PKCE: La Defensa Stateless para Clientes Públicos

Un cliente confidencial (backend de servidor web) tiene un client_secret que prueba identidad en el intercambio de tokens. Un cliente público (app mobile, SPA) no puede — todo lo que se shipea al browser/dispositivo es reverse-engineereable. Sin PKCE, un cliente público es vulnerable a interceptación del code de autorización: una app maliciosa en el mismo dispositivo registra el mismo custom URL scheme, intercepta la redirección con el auth code, y lo intercambia. PKCE (Proof Key for Code Exchange) lo arregla: (1) el cliente genera un code_verifier random de 43-128 chars, computa code_challenge = base64url(sha256(code_verifier)), y manda code_challenge + code_challenge_method=S256 con la auth request. (2) El AS guarda el challenge junto con el code emitido. (3) El cliente intercambia el code + el code_verifier original. (4) El AS verifica sha256(verifier) == challenge — solo el cliente original conoce el verifier, así que un interceptor que solo tiene el code no lo puede redimir. PKCE ahora es mandatorio para clientes públicos (RFC 7636) y recomendado para todos los clientes (OAuth 2.1).

state, nonce, redirect_uri y Almacenamiento de Tokens

state — un valor random por request que el cliente manda a /authorize y verifica al volver. Frena CSRF en el step de redirección (un atacante no puede iniciar un flow OAuth que aterrice en el redirect_uri de la víctima con el code del atacante, porque el state no matchearía). nonce (OIDC) — valor random mandado en la auth request, devuelto en el claim del id_token, previene replay. El redirect_uri debe estar registrado con match exacto en el AS — registrations open-redirect / wildcard / prefix-match han causado muchos account takeovers (el atacante hace que el AS redirija a evil.com/callback, cosechando el code). Almacenamiento de tokens: las SPAs deberían usar cookies HttpOnly Secure SameSite=Lax vía un BFF, no localStorage. Las apps mobile usan el OS Keychain (iOS) / Keystore (Android). Los refresh tokens DEBEN ser single-use con detección de rotación — el reuso señala compromiso. El documento BCP 212 (current best practice) es el checklist de seguridad de OAuth.

Puntos clave

  • Usá Authorization Code + PKCE para todo. Implicit y Resource Owner Password están deprecados en OAuth 2.1.
  • El redirect_uri debe estar registrado con match exacto en el AS. Las registrations open-redirect o wildcard son causa principal de account takeover basado en OAuth.
  • Nunca guardes refresh tokens en localStorage en una SPA. Usá cookies HttpOnly vía un BFF, o moveté a una arquitectura basada en sesiones.

Code example

// SPA — generar par PKCE
const codeVerifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
const codeChallenge = base64UrlEncode(
  await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier))
);
sessionStorage.setItem("pkce_verifier", codeVerifier);
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)));
sessionStorage.setItem("oauth_state", state);

// Redirigir al AS
location.href = `https://auth.example.com/authorize?` +
  `response_type=code` +
  `&client_id=${CLIENT_ID}` +
  `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
  `&scope=openid%20profile` +
  `&state=${state}` +
  `&code_challenge=${codeChallenge}` +
  `&code_challenge_method=S256`;

// Handler del callback — verificar state, intercambiar code+verifier por tokens
const params = new URLSearchParams(location.search);
if (params.get("state") !== sessionStorage.getItem("oauth_state")) {
  throw new Error("state no coincide — posible CSRF");
}
const tokens = await fetch("https://auth.example.com/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code: params.get("code"),
    redirect_uri: REDIRECT_URI,
    client_id: CLIENT_ID,
    code_verifier: sessionStorage.getItem("pkce_verifier")
  })
}).then(r => r.json());