
El Error Silencioso del OAuth: Por Qué el Token Nunca Debería Pasar por la URL
El login OAuth funciona. El popup se abre, el usuario aprueba en Google, el popup se cierra, y tu aplicación recibe ?token=eyJhbGciOi... en la URL de callback. Leés el token de la query string, lo guardás en algún lado, y la demo funciona perfecto en tu pantalla.
Ese es exactamente el momento en que la mayoría de las implementaciones de OAuth introducen una falla de seguridad silenciosa — una que pasa cualquier test manual, cualquier QA, y solo aparece cuando alguien audita los logs de producción seis meses después.
1. Por qué la barra de direcciones no es un lugar seguro para un secreto
Una URL parece efímera — aparece, el usuario navega a otro lado, desaparece. En la práctica, queda registrada y replicada en varios lugares que no controlás:
- Logs de acceso. Servidores web, proxies reversos, CDNs e incluso el load balancer suelen loguear la URL completa de cada request, query string incluida, por defecto — muchas veces durante meses, en texto plano, en un sistema de logging al que decenas de personas tienen acceso de lectura.
- Historial del navegador. Queda guardado localmente, sincronizado entre dispositivos si el usuario tiene sync de navegador activado, y sobrevive mucho más allá de la sesión de login.
- Header
Referer. Si la página que recibe el token carga algún recurso de terceros (una fuente, un script de analytics, un pixel de tracking) antes de que limpies la URL, el navegador puede enviar la URL completa — token incluido — comoReferera ese tercero. - Herramientas de observabilidad del lado del cliente. Sentry, LogRocket, Google Analytics y similares frecuentemente capturan la URL actual como parte del contexto de error o de sesión de forma automática. Un SDK de terceros mal configurado puede literalmente exfiltrar el token fuera de tu infraestructura sin que nadie haya escrito una línea de código para eso — solo configuró el SDK con los valores por defecto.
Nada de esto es un ataque sofisticado. Es el comportamiento normal y documentado de navegadores, proxies y herramientas de observabilidad — lo que hace que esta filtración sea sistémica, no una explotación puntual.
2. El patrón correcto: un código, no el token
La solución no es nueva — es literalmente la razón por la que el Authorization Code Grant existe como flujo separado del Implicit Grant en la especificación OAuth 2.0 (y por qué el Implicit Grant fue formalmente descontinuado en OAuth 2.1). La idea:
- El proveedor (Google, GitHub) redirige de vuelta a tu aplicación no con el token real, sino con un código de autorización — una cadena opaca, de un solo uso, con un TTL corto (típicamente 30-60 segundos).
- El frontend recibe ese código en la URL (
?code=xxx) — que sigue siendo una URL, pero ahora lleva algo que no vale nada por sí solo y expira casi al instante. - El frontend hace una petición
POST(nuncaGET, nunca en la URL) a tu propio backend, canjeando ese código por el token real. - El backend valida el código (¿existe? ¿ya se usó? ¿expiró?), lo canjea con el proveedor OAuth por los tokens reales, y devuelve el resultado al frontend como una cookie
httpOnly,secure,sameSite— nunca como JSON que el JavaScript del frontend pueda leer.
// auth.controller.ts (NestJS) — endpoint de callback del proveedor
@Get('google/callback')
async googleCallback(@Query('code') providerCode: string, @Res() res: Response) {
const { accessToken } = await this.authService.exchangeWithGoogle(providerCode);
// Genera un código PROPIO, opaco, de un solo uso y corta duración —
// nunca redirige con el token real en la URL.
const exchangeCode = await this.authService.issueShortLivedExchangeCode(accessToken);
return res.redirect(`${process.env.FRONTEND_URL}/auth/callback?code=${exchangeCode}`);
}
// endpoint separado, llamado vía POST por el frontend
@Post('exchange')
async exchange(@Body('code') code: string, @Res() res: Response) {
const { jwt } = await this.authService.redeemExchangeCode(code); // un solo uso, TTL corto
res.cookie('session', jwt, {
httpOnly: true,
secure: true,
sameSite: 'lax',
});
return res.json({ ok: true });
}
Fijate qué cambia: aunque ese exchangeCode de la URL se filtre en algún log, no sirve para nada después de canjearse una vez (o después de unos segundos). El token real nunca aparece en texto plano en ningún lado que no sea la memoria del servidor y una cookie httpOnly — que, por definición, el JavaScript del navegador ni siquiera puede leer, lo que también mitiga el robo de tokens vía XSS.
3. Dos capas extra que suelen faltar
- Parámetro
state: un valor aleatorio generado antes de redirigir al proveedor, validado cuando vuelve. Sin esto, un atacante puede iniciar su propio flujo OAuth y engañar a una víctima para que complete su login — un CSRF clásico contra el flujo de autenticación. - PKCE (Proof Key for Code Exchange): pensado originalmente para apps móviles/SPAs sin un client secret seguro, hoy se recomienda incluso para clientes confidenciales. Un
code_verifiergenerado en el cliente, hasheado en uncode_challengeenviado al inicio del flujo, y elcode_verifieroriginal enviado al canjear el código — esto garantiza que solo quien inició el flujo pueda completarlo, aunque el código de autorización sea interceptado en el camino.
Conclusión
El login "funciona" en los dos diseños — con el token en la URL, o con el patrón de exchange code. La diferencia nunca aparece en el happy path que probás a mano; aparece seis meses después, en un log de acceso de un proxy que nadie recordaba que existía, o en un reporte de seguridad preguntando por qué los tokens de sesión de usuarios aparecen en texto plano en un sistema de observabilidad de terceros.
Tratá la barra de direcciones como un canal público, registrado permanentemente — porque es exactamente eso. Cualquier cosa que valga algo por sí sola no debería pasar por ahí, ni por un segundo.
Comentarios
Cargando comentarios...
Únete a la conversación
Inicia sesión con tu cuenta para comentar este artículo.