OAuth 2.1 ya llegó: Qué cambió, qué se deprecó y cómo migrar tu app
Si shipeaste una single-page application antes de 2024, tu implementación de OAuth probablemente es insegura. No "teóricamente vulnerable" — realmente explotable.
¿El Implicit Grant flow que todos los tutoriales de React te enseñaron a usar? Eliminado en OAuth 2.1. ¿El flow de Resource Owner Password Credentials (ROPC) que usa tu app mobile? También eliminado. ¿Bearer tokens en query strings de URLs? Prohibido.
OAuth 2.1 no es un bump de versión menor. Son diez años de lecciones de seguridad codificadas en spec, y rompe código real de producción. Las librerías que usás ya están shipeando defaults de OAuth 2.1. Los Identity Providers están deprecando endpoints legacy. Si todavía no migraste, estás viviendo de prestado.
Esta guía cubre cada breaking change en OAuth 2.1, explica por qué se tomó cada decisión (para que sepas que no es burocracia arbitraria), y te da código TypeScript listo para producción para migrar tus implementaciones existentes.
¿Qué es OAuth 2.1, exactamente?
OAuth 2.1 no es un protocolo nuevo. Es una consolidación de OAuth 2.0 (RFC 6749) más cada RFC de best practices de seguridad publicado desde 2012. Pensalo como OAuth 2.0 con catorce años de errata, advisories de seguridad y recomendaciones de "deberías estar haciendo esto" metidas directamente en la spec core.
Los RFCs clave que absorbe:
| RFC | Qué cubre | Impacto en OAuth 2.1 |
|---|---|---|
| RFC 7636 | PKCE (Proof Key for Code Exchange) | Ahora obligatorio para todos los clientes |
| RFC 7009 | Revocación de tokens | Integrado como feature core |
| RFC 8252 | Best practices para apps nativas | Redirects loopback estandarizados |
| RFC 9207 | Identificación de Issuer del Authorization Server | Verificación de issuer requerida |
| RFC 9126 | Pushed Authorization Requests (PAR) | Recomendado para flujos de alta seguridad |
| RFC 9449 | DPoP (Demonstration of Proof-of-Possession) | Recomendado sobre bearer tokens |
El efecto práctico: leer una spec en vez de seis. Pero el costo de migración es real, porque OAuth 2.1 elimina flujos que millones de aplicaciones todavía usan.
Los cuatro breaking changes
1. Implicit Grant murió
Qué se eliminó: Todo el flujo response_type=token.
En OAuth 2.0, el Implicit Grant fue diseñado para apps basadas en browser que no podían almacenar un client secret de forma segura. El authorization server devolvía un access token directamente en el URL fragment (#access_token=...). Era simple. También era un desastre de seguridad.
Por qué es inseguro:
// La cadena de vulnerabilidades del Implicit Flow
1. El usuario hace clic en "Login con Google"
2. Redirect a: https://auth.example.com/authorize?
response_type=token&
client_id=my-spa&
redirect_uri=https://app.example.com/callback
3. Después de autenticarse, redirect de vuelta:
https://app.example.com/callback#access_token=eyJhbGciOi...
// 🚨 Problema 1: Token en el URL fragment
// - Visible en el historial del browser
// - Logueado por cualquier proxy/CDN entre el usuario y el server
// - Accesible para cualquier JavaScript en la página (XSS = game over)
// 🚨 Problema 2: No hay forma de verificar quién recibe el token
// - Un atacante puede interceptar el redirect y robar el token
// - Sin PKCE, sin code exchange, sin paso de verificación
// 🚨 Problema 3: Sin refresh tokens
// - Tokens de corta vida significan reautenticación constante
// - Los usuarios se frustran, los devs extienden el lifetime
// - Tokens de larga vida en URL fragments = peor seguridad
El reemplazo en OAuth 2.1: Authorization Code flow con PKCE. Cada SPA, cada app mobile, cada cliente que usaba Implicit tiene que migrar.
// ❌ Antes: Implicit Grant (ELIMINADO en OAuth 2.1) const authUrl = new URL('https://auth.example.com/authorize'); authUrl.searchParams.set('response_type', 'token'); // ← Prohibido authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); window.location.href = authUrl.toString(); // ✅ Ahora: Authorization Code + PKCE const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier); const authUrl = new URL('https://auth.example.com/authorize'); authUrl.searchParams.set('response_type', 'code'); // ← Code, no token authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); window.location.href = authUrl.toString();
2. PKCE es obligatorio para TODOS los clientes
Qué cambió: PKCE era opcional en OAuth 2.0, recomendado solo para clientes públicos (SPAs, apps mobile). En OAuth 2.1, es obligatorio para todo tipo de cliente — incluyendo aplicaciones server-side confidenciales que ya tienen un client secret.
Por qué incluso los clientes confidenciales necesitan PKCE:
Incluso con un client secret, el authorization code puede ser interceptado durante el redirect. PKCE previene ataques de inyección de authorization code donde un atacante sustituye su propio code en la sesión de la víctima. El client secret protege el token endpoint; PKCE protege el flujo de autorización.
Cómo funciona PKCE:
import { createHash, randomBytes } from 'crypto'; // Paso 1: Generar un code verifier criptográficamente aleatorio function generateCodeVerifier(): string { // 32 bytes = 43 caracteres en base64url return randomBytes(32) .toString('base64url'); } // Paso 2: Crear el code challenge a partir del verifier async function generateCodeChallenge(verifier: string): Promise<string> { // Hash SHA-256, luego base64url encode const hash = createHash('sha256') .update(verifier) .digest(); return Buffer.from(hash).toString('base64url'); } // Paso 3: Incluir en la authorization request const verifier = generateCodeVerifier(); const challenge = await generateCodeChallenge(verifier); // Enviar challenge al authorization server // Guardar verifier de forma segura (sessionStorage, no localStorage) sessionStorage.setItem('pkce_verifier', verifier); // Paso 4: Incluir verifier en el token exchange async function exchangeCode(code: string): Promise<TokenResponse> { const verifier = sessionStorage.getItem('pkce_verifier'); const response = 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, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier!, // ← Prueba de que nosotros hicimos el request original }), }); return response.json(); }
El flujo de verificación:
Client Auth Server
| |
|-- code_challenge=SHA256(v) ----> | Authorization Request
| | (guarda challenge)
|<-------- code=abc123 ----------- | Authorization Response
| |
|-- code=abc123 -----------------> | Token Request
| code_verifier=v | (calcula SHA256(v),
| | compara con challenge guardado)
|<-------- access_token ---------- | Token Response
| |
// Si un atacante intercepta el code, no puede canjearlo
// porque no tiene el code_verifier que matchea
// el code_challenge enviado en el request original.
3. ROPC (Resource Owner Password Credentials) murió
Qué se eliminó: El flujo grant_type=password.
ROPC permitía a las aplicaciones recolectar el username y password del usuario directamente y canjearlos por tokens. Fue diseñado como un "path de migración" para apps legacy que no podían redirigir a un authorization server. En la práctica, se convirtió en una muleta que eliminó todos los beneficios de seguridad de OAuth.
Por qué se eliminó:
// ❌ ROPC: Tu app maneja credenciales crudas const response = await fetch('https://auth.example.com/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'password', // ← Eliminado en OAuth 2.1 username: '[email protected]', // ← La app ve el password password: 'hunter2', // ← Riesgo de phishing, credential stuffing client_id: CLIENT_ID, }), }); // 🚨 Problemas: // 1. La app tiene el password crudo del usuario — viola el propósito de OAuth // 2. Sin soporte MFA — no se puede hacer 2FA con password grant // 3. Sin pantalla de consentimiento — el usuario no controla permisos // 4. Entrena usuarios a tipear passwords en apps de terceros // 5. Si tu app es comprometida, todos los passwords quedan expuestos
El reemplazo en OAuth 2.1 depende de tu caso de uso:
| Escenario | Flujo anterior | Flujo nuevo |
|---|---|---|
| Login de usuario (web/mobile) | ROPC | Authorization Code + PKCE |
| Auth machine-to-machine | ROPC (abusado) | Client Credentials |
| Auth de herramienta CLI | ROPC | Device Authorization (RFC 8628) |
| Migración de sistema legacy | ROPC | Token Exchange (RFC 8693) |
Device Authorization flow para CLIs:
// ✅ Device Authorization Flow (reemplaza ROPC para herramientas CLI) async function deviceLogin(): Promise<void> { // Paso 1: Pedir un device code const deviceResponse = await fetch('https://auth.example.com/device', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: CLI_CLIENT_ID, scope: 'read write', }), }); const { device_code, user_code, verification_uri, interval } = await deviceResponse.json(); // Paso 2: Mostrar código al usuario console.log(`Abrí ${verification_uri} e ingresá el código: ${user_code}`); // Paso 3: Hacer polling hasta que complete while (true) { await sleep(interval * 1000); const tokenResponse = await fetch('https://auth.example.com/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code, client_id: CLI_CLIENT_ID, }), }); const result = await tokenResponse.json(); if (result.access_token) { saveTokens(result); console.log('¡Autenticación exitosa!'); return; } if (result.error === 'expired_token') { throw new Error('Login expirado. Intentá de nuevo.'); } // 'authorization_pending' o 'slow_down' → seguir haciendo polling } }
4. Matching exacto de redirect URIs
Qué cambió: OAuth 2.0 permitía matching "laxo" de redirect URIs — matching por prefijo, wildcards en subdominios y otros patrones flexibles. OAuth 2.1 requiere matching exacto de strings.
// ❌ OAuth 2.0: Estos patrones "laxos" eran permitidos // Registrado: https://app.example.com/callback // Match: https://app.example.com/callback?foo=bar ← OK // Match: https://app.example.com/callback/extra ← OK (prefix match) // Match: https://*.example.com/callback ← OK (wildcard) // ✅ OAuth 2.1: Solo matching exacto de strings // Registrado: https://app.example.com/callback // Match: https://app.example.com/callback ← OK // No match: https://app.example.com/callback?foo=bar ← RECHAZADO // No match: https://app.example.com/callback/ ← RECHAZADO (slash final) // No match: https://sub.example.com/callback ← RECHAZADO
Por qué importa más de lo que pensás:
Las vulnerabilidades de open redirect eran uno de los ataques OAuth más comunes. Un atacante podía registrar un redirect URI como https://evil.com/steal y, si el authorization server usaba prefix matching con una comparación laxa, redirigir el authorization code a su server.
Checklist de migración:
// Auditar tus registros de redirect URIs const redirectUris = { // ❌ Problemas a corregir: bad: [ 'https://app.example.com/*', // Wildcard — no permitido 'https://app.example.com/auth/callback/', // Mismatch de trailing slash 'http://localhost:3000/callback', // HTTP en producción ], // ✅ Registros correctos: good: [ 'https://app.example.com/auth/callback', // Match exacto, sin trailing slash 'https://staging.example.com/auth/callback', // Entrada separada para staging 'http://127.0.0.1:3000/callback', // Loopback para dev (RFC 8252) 'http://[::1]:3000/callback', // Loopback IPv6 para dev ], }; // Nota: Para desarrollo, RFC 8252 permite HTTP en direcciones loopback // (127.0.0.1 y [::1]), pero NO en "localhost" (problema de resolución DNS)
Requisitos de seguridad adicionales
Más allá de los cuatro breaking changes, OAuth 2.1 refuerza varias prácticas de seguridad:
No más bearer tokens en URLs
// ❌ OAuth 2.0 permitía esto fetch('https://api.example.com/data?access_token=eyJhbGciOi...'); // Token en URL = logueado en access logs del server, proxy, CDN, historial del browser // ✅ OAuth 2.1: Solo Authorization header fetch('https://api.example.com/data', { headers: { 'Authorization': 'Bearer eyJhbGciOi...', }, }); // ✅ O en el body del POST para form submissions fetch('https://api.example.com/data', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ access_token: 'eyJhbGciOi...', }), });
Rotación de refresh tokens
OAuth 2.1 recomienda fuertemente (en la práctica, requiere) rotación de refresh tokens. Cada vez que se usa un refresh token, el anterior se invalida y se emite uno nuevo.
interface TokenStore { accessToken: string; refreshToken: string; expiresAt: number; } async function refreshAccessToken(store: TokenStore): Promise<TokenStore> { const response = await fetch('https://auth.example.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: store.refreshToken, client_id: CLIENT_ID, }), }); if (!response.ok) { // El refresh token ya se usó o fue revocado // Forzar reautenticación throw new AuthenticationRequiredError('Sesión expirada'); } const data = await response.json(); return { accessToken: data.access_token, refreshToken: data.refresh_token, // ← ¡Nuevo refresh token! expiresAt: Date.now() + data.expires_in * 1000, }; } // 🚨 Crítico: Manejar race conditions // Si dos tabs intentan refrescar al mismo tiempo, uno recibe // el nuevo refresh token y el otro falla porque el viejo // se invalidó. Usá un mutex o patrón de leader election: class TokenRefresher { private refreshPromise: Promise<TokenStore> | null = null; async getValidToken(store: TokenStore): Promise<TokenStore> { if (store.expiresAt > Date.now() + 30_000) { return store; // Todavía válido con 30s de buffer } // Deduplicar intentos concurrentes de refresh if (!this.refreshPromise) { this.refreshPromise = refreshAccessToken(store).finally(() => { this.refreshPromise = null; }); } return this.refreshPromise; } }
Migración completa: ejemplo React SPA
Acá tenés un before/after completo para una SPA React típica que usaba el Implicit Grant:
// === auth.ts — Módulo de autenticación compatible con OAuth 2.1 === const AUTH_CONFIG = { authority: 'https://auth.example.com', clientId: 'my-spa-client', redirectUri: 'https://app.example.com/auth/callback', // Match exacto scope: 'openid profile email', tokenEndpoint: 'https://auth.example.com/token', authorizeEndpoint: 'https://auth.example.com/authorize', }; // --- Utilidades PKCE --- function generateRandomString(length: number): string { const array = new Uint8Array(length); crypto.getRandomValues(array); return btoa(String.fromCharCode(...array)) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } async function sha256(plain: string): Promise<ArrayBuffer> { const encoder = new TextEncoder(); return crypto.subtle.digest('SHA-256', encoder.encode(plain)); } async function generatePKCE(): Promise<{ verifier: string; challenge: string; }> { const verifier = generateRandomString(32); const hashed = await sha256(verifier); const challenge = btoa(String.fromCharCode(...new Uint8Array(hashed))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); return { verifier, challenge }; } // --- Flujo de Login --- export async function login(): Promise<void> { const { verifier, challenge } = await generatePKCE(); const state = generateRandomString(16); // Guardar PKCE verifier y state en sessionStorage // (sobrevive el redirect, se limpia al cerrar el tab) sessionStorage.setItem('oauth_code_verifier', verifier); sessionStorage.setItem('oauth_state', state); const params = new URLSearchParams({ response_type: 'code', client_id: AUTH_CONFIG.clientId, redirect_uri: AUTH_CONFIG.redirectUri, scope: AUTH_CONFIG.scope, state, code_challenge: challenge, code_challenge_method: 'S256', }); window.location.href = `${AUTH_CONFIG.authorizeEndpoint}?${params.toString()}`; } // --- Callback Handler --- export async function handleCallback(): Promise<{ accessToken: string; refreshToken: string; idToken: string; }> { const params = new URLSearchParams(window.location.search); const code = params.get('code'); const state = params.get('state'); // Verificar state para prevenir CSRF const savedState = sessionStorage.getItem('oauth_state'); if (!state || state !== savedState) { throw new Error('Parámetro state inválido — posible ataque CSRF'); } const verifier = sessionStorage.getItem('oauth_code_verifier'); if (!verifier) { throw new Error('Falta el PKCE verifier — reiniciá el flujo de login'); } // Canjear code por tokens const response = await fetch(AUTH_CONFIG.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code!, redirect_uri: AUTH_CONFIG.redirectUri, client_id: AUTH_CONFIG.clientId, code_verifier: verifier, }), }); if (!response.ok) { const error = await response.json(); throw new Error(`Falló el token exchange: ${error.error_description}`); } // Limpiar sessionStorage.removeItem('oauth_code_verifier'); sessionStorage.removeItem('oauth_state'); // Limpiar URL window.history.replaceState({}, '', window.location.pathname); return response.json(); } // --- Gestión de Tokens --- export async function refreshToken( currentRefreshToken: string ): Promise<{ accessToken: string; refreshToken: string; }> { const response = await fetch(AUTH_CONFIG.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: currentRefreshToken, client_id: AUTH_CONFIG.clientId, }), }); if (!response.ok) { // Refresh token rotado y ya usado, o revocado throw new Error('SESSION_EXPIRED'); } return response.json(); } // --- Logout --- export async function logout(idToken: string): Promise<void> { // Revocar tokens server-side await fetch('https://auth.example.com/revoke', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ token: idToken, token_type_hint: 'access_token', client_id: AUTH_CONFIG.clientId, }), }); // Limpiar estado local y redirigir sessionStorage.clear(); window.location.href = 'https://auth.example.com/logout?' + new URLSearchParams({ id_token_hint: idToken, post_logout_redirect_uri: 'https://app.example.com', }).toString(); }
Guía de migración por Identity Provider
Cada IdP principal tiene timelines diferentes para forzar la semántica de OAuth 2.1:
Auth0 / Okta
// Auth0 SDK v2+ ya usa Authorization Code + PKCE por defecto // Migración: Actualizá el SDK y eliminá la config legacy // ❌ Configuración Auth0 vieja const auth0 = new Auth0Client({ domain: 'your-tenant.auth0.com', clientId: 'YOUR_CLIENT_ID', useRefreshTokens: false, // ← Común en setups con Implicit cacheLocation: 'localstorage', // ← Necesario sin refresh tokens }); // ✅ Nueva configuración Auth0 (compatible con OAuth 2.1) const auth0 = new Auth0Client({ domain: 'your-tenant.auth0.com', clientId: 'YOUR_CLIENT_ID', authorizationParams: { redirect_uri: 'https://app.example.com/callback', // Match exacto }, useRefreshTokens: true, // ← Habilitar rotación de refresh tokens cacheLocation: 'memory', // ← En memoria es más seguro });
Google OAuth
// Google deprecó el Implicit flow para apps nuevas en 2022 // Apps existentes: la deadline de migración varía // ❌ Antes: Google Sign-In (Implicit) // <script src="https://apis.google.com/js/platform.js"></script> // gapi.auth2.init({ client_id: '...' }) — deprecado // ✅ Ahora: Google Identity Services (Authorization Code + PKCE) // Usa la nueva librería GIS con code flow google.accounts.oauth2.initCodeClient({ client_id: GOOGLE_CLIENT_ID, scope: 'openid profile email', ux_mode: 'redirect', redirect_uri: 'https://app.example.com/auth/google/callback', state: generateState(), });
Microsoft Entra ID (Azure AD)
// MSAL.js v2+ usa Authorization Code + PKCE por defecto import { PublicClientApplication } from '@azure/msal-browser'; const msalConfig = { auth: { clientId: 'YOUR_CLIENT_ID', authority: 'https://login.microsoftonline.com/YOUR_TENANT_ID', redirectUri: 'https://app.example.com/auth/callback', // Match exacto }, cache: { cacheLocation: 'sessionStorage', // ← Más seguro que localStorage storeAuthStateInCookie: false, }, }; const msalInstance = new PublicClientApplication(msalConfig); // Login — PKCE es automático con MSAL v2+ await msalInstance.loginRedirect({ scopes: ['openid', 'profile', 'User.Read'], });
Seguridad avanzada más allá de OAuth 2.1
OAuth 2.1 establece el piso, no el techo. Para apps de producción que manejan datos sensibles, considerá estas medidas adicionales:
DPoP (Demonstration of Proof-of-Possession)
DPoP vincula access tokens a un cliente específico, previniendo robo y replay de tokens. En vez de bearer tokens (que cualquiera puede usar si los roba), los tokens DPoP están criptográficamente vinculados al key pair del cliente.
// DPoP: Tokens con Proof-of-Possession async function createDPoPProof( url: string, method: string, accessToken?: string ): Promise<string> { // Generar o recuperar tu key pair DPoP const keyPair = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify'] ); const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: await crypto.subtle.exportKey('jwk', keyPair.publicKey), }; const payload = { jti: crypto.randomUUID(), htm: method, htu: url, iat: Math.floor(Date.now() / 1000), // Incluir hash del access token para token binding ...(accessToken && { ath: await sha256Base64url(accessToken), }), }; return signJWT(header, payload, keyPair.privateKey); } // Uso: Adjuntar DPoP proof a cada API request const dpopProof = await createDPoPProof( 'https://api.example.com/data', 'GET', accessToken ); fetch('https://api.example.com/data', { headers: { 'Authorization': `DPoP ${accessToken}`, 'DPoP': dpopProof, }, });
Pushed Authorization Requests (PAR)
PAR previene la manipulación de authorization requests enviando los parámetros directamente al server antes del redirect:
// PAR: Pushear parámetros de autorización al server primero async function initiateLoginWithPAR(): Promise<void> { const { verifier, challenge } = await generatePKCE(); const state = generateRandomString(16); // Paso 1: Pushear authorization request al server const parResponse = await fetch('https://auth.example.com/par', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: AUTH_CONFIG.clientId, redirect_uri: AUTH_CONFIG.redirectUri, scope: AUTH_CONFIG.scope, response_type: 'code', state, code_challenge: challenge, code_challenge_method: 'S256', }), }); const { request_uri } = await parResponse.json(); // Paso 2: Redirigir solo con el request_uri // (Todos los parámetros están guardados server-side, no en la URL) sessionStorage.setItem('oauth_code_verifier', verifier); sessionStorage.setItem('oauth_state', state); window.location.href = `${AUTH_CONFIG.authorizeEndpoint}?` + `client_id=${AUTH_CONFIG.clientId}&` + `request_uri=${encodeURIComponent(request_uri)}`; }
OAuth 2.1 y agentes de IA
Una de las implicaciones menos discutidas de OAuth 2.1 es su rol en el ecosistema emergente de agentes de IA. A medida que los servidores MCP y los protocolos A2A maduran, OAuth 2.1 provee la base de seguridad para acceso delegado de IA.
// OAuth para Agentes de IA: acceso con scope limitado y tiempo acotado // Un agente de IA NUNCA debería tener permisos completos del usuario const agentTokenRequest = { grant_type: 'authorization_code', code: authorizationCode, code_verifier: pkceVerifier, client_id: 'ai-agent-client', redirect_uri: 'https://agent.example.com/oauth/callback', // ← Scopes estrechos para agentes de IA scope: 'read:emails read:calendar', // NO 'write:*' // ← Pedir tokens de corta vida // (Las tareas de agentes no necesitan acceso de varios días) }; // Para autenticación de servidores MCP: // La spec de MCP recomienda OAuth 2.1 con PKCE // para acceso a herramientas de terceros vía agentes de IA. // Esto asegura que los usuarios consientan explícitamente // qué datos puede acceder el agente de IA en su nombre.
Por qué importa: Un agente de IA que accede a tus repos de GitHub, canales de Slack y bandeja de email necesita autorización verificable criptográficamente, con scope estrecho y tiempo limitado — no una API key estática en una variable de entorno. OAuth 2.1 con PKCE + DPoP provee exactamente esto.
Errores comunes de migración
Error 1: Guardar el PKCE verifier en localStorage
// ❌ Mal: localStorage persiste entre sesiones localStorage.setItem('pkce_verifier', verifier); // Un ataque XSS puede leer esto y completar el code exchange // ✅ Bien: sessionStorage se limpia al cerrar el tab sessionStorage.setItem('pkce_verifier', verifier); // Todavía vulnerable a XSS, pero con ventana de exposición más corta // ✅ Mejor: Guardar server-side en una cookie HTTP-only // (para patrones BFF / Backend-for-Frontend)
Error 2: No manejar race conditions del refresh token
// ❌ Mal: Dos API calls concurrentes disparan dos intentos de refresh // Tab 1: refresh_token=abc → obtiene nuevo refresh_token=def // Tab 2: refresh_token=abc → FALLA (abc ya fue rotado) // Tab 2 desloguea al usuario innecesariamente // ✅ Bien: Centralizar el refresh de tokens con un mutex class TokenManager { private refreshLock: Promise<void> | null = null; async getAccessToken(): Promise<string> { const tokens = this.getStoredTokens(); if (this.isExpired(tokens)) { if (!this.refreshLock) { this.refreshLock = this.doRefresh().finally(() => { this.refreshLock = null; }); } await this.refreshLock; } return this.getStoredTokens().accessToken; } }
Error 3: Ignorar el parámetro state
// ❌ Mal: Sin parámetro state = sin protección CSRF const authUrl = `${authEndpoint}?response_type=code&client_id=${clientId}`; // Un atacante puede crafted una URL que loguee a la víctima en la cuenta del atacante // ✅ Bien: Siempre generar y validar state const state = crypto.randomUUID(); sessionStorage.setItem('oauth_state', state); const authUrl = `${authEndpoint}?response_type=code&client_id=${clientId}&state=${state}`; // En el callback: verificar que el state matchee antes de canjear el code
Error 4: Usar "localhost" para redirects de desarrollo
// ❌ Mal: "localhost" se resuelve via DNS (puede ser hijackeado) const devRedirect = 'http://localhost:3000/callback'; // ✅ Bien: Usar direcciones IP loopback (RFC 8252) const devRedirect = 'http://127.0.0.1:3000/callback'; // O IPv6: 'http://[::1]:3000/callback' // Se resuelven localmente sin DNS, previniendo redirect hijacking
El checklist de migración
Usá este checklist para auditar tu implementación OAuth existente:
## Checklist de Migración OAuth 2.1 ### Crítico (Arreglar antes de que el IdP lo fuerce) - [ ] Eliminar todo uso de `response_type=token` (Implicit Grant) - [ ] Eliminar todo uso de `grant_type=password` (ROPC) - [ ] Agregar PKCE a todos los authorization code flows - [ ] Cambiar a matching exacto de redirect URIs - [ ] Eliminar bearer tokens de query strings de URLs ### Alta Prioridad - [ ] Implementar rotación de refresh tokens - [ ] Manejar race conditions del refresh token (patrón mutex) - [ ] Guardar PKCE verifiers en sessionStorage, no localStorage - [ ] Migrar redirects de desarrollo de localhost a 127.0.0.1 - [ ] Agregar parámetro state a todos los authorization requests ### Recomendado - [ ] Implementar DPoP para token binding de alta seguridad - [ ] Usar PAR (Pushed Authorization Requests) para flujos sensibles - [ ] Limitar scope de tokens de agentes de IA (solo lectura, tiempo limitado) - [ ] Auditar librerías de terceros para cumplimiento OAuth 2.1 - [ ] Configurar revocación de tokens al hacer logout
Conclusión
OAuth 2.1 no agrega complejidad — remueve las aristas filosas que causaron una década de incidentes de seguridad. El Implicit Grant fue un atajo que creó vulnerabilidades reales. ROPC fue un path de migración que se volvió permanente. Los wildcards en redirects eran convenientes hasta que dejaron de serlo.
La migración es directa para la mayoría de las aplicaciones:
-
Reemplazá Implicit Grant con Authorization Code + PKCE. Este es el cambio de mayor impacto. Si usás un SDK moderno (Auth0, MSAL, Firebase), actualizar la versión del SDK muchas veces lo maneja automáticamente.
-
Eliminá los flujos ROPC. Cambiá al reemplazo apropiado: Authorization Code para apps facing users, Client Credentials para M2M, y Device Authorization para CLIs.
-
Auditá tus redirect URIs. Registrá cada entorno (producción, staging, desarrollo) como una URI de match exacto. Usá
127.0.0.1en vez delocalhostpara desarrollo local. -
Habilitá la rotación de refresh tokens. Implementá el patrón mutex para manejar intentos de refresh concurrentes entre tabs.
-
Dejá de poner tokens en URLs. Usá el header
Authorizationexclusivamente.
Los Identity Providers ya están moviéndose — Auth0, Okta, Google y Microsoft han deprecado flujos legacy o establecido timelines de enforcement. Migrar ahora, en tu propio schedule, es significativamente menos doloroso que migrar bajo presión de deadline cuando tu autenticación empieza a devolver errores 400.
Explora herramientas relacionadas
Prueba estas herramientas gratuitas de Pockit