Saltar al contenido
< samuelsantana.dev />
Volver al BlogPortada: una URL de callback con ?token= que se ramifica en logs de acceso, historial, header Referer y SDK de analytics; al lado, el flujo seguro: ?code=, POST /auth/exchange y cookie httpOnly.

El Error Silencioso del OAuth: Por Qué el Token Nunca Debería Pasar por la URL

Samuel Santana
Publicado el 25 de julio de 2026(Editado el 27 de julio de 2026)
OAuthSecurityNext.js

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. Lees el token de la query string, lo guardas en algún lado, y la demo funciona perfectamente en tu pantalla.

Ese es exactamente el momento en que muchas 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 controlas:

  • Logs de acceso. Servidores web, proxies reversos, CDNs e incluso el load balancer suelen registrar la URL completa de cada petición, 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 antes de que limpies la URL, las peticiones a tu propio origen llevan la URL completa — token incluido — en el Referer, y esta termina en los logs de tu servidor y de tu CDN. Para terceros (una fuente, un script de analytics, un pixel de tracking), el valor por defecto de los navegadores modernos (strict-origin-when-cross-origin) envía solo el origen; la URL completa solo viaja si la página relaja su Referrer-Policy (unsafe-url, no-referrer-when-downgrade).
  • 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 está desaconsejado por el RFC 9700, el OAuth 2.0 Security BCP, y fue eliminado de OAuth 2.1, todavía en borrador). La idea:

  1. 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 (el RFC 6749 recomienda 10 minutos como máximo; GitHub usa exactamente 10).
  2. 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 en pocos minutos.
  3. El frontend hace una petición POST (nunca GET, nunca en la URL) a tu propio backend, canjeando ese código por el token real.
  4. El backend canjea el código con el proveedor OAuth por los tokens reales (es el proveedor quien rechaza un código inexistente, ya usado o expirado), y devuelve el resultado al frontend como una cookie httpOnly, secure, sameSite — nunca como JSON que el JavaScript del frontend pueda leer.

En el flujo con popup del snippet de abajo, el proveedor redirige directamente al backend, que hace ese canje en el momento; el ?code= que llega al frontend es un segundo código, emitido por el propio backend, con las mismas propiedades: opaco, de un solo uso y con un TTL corto (60 segundos, por ejemplo).

// 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 });
}

Fíjate en lo que 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. Un detalle que el snippet no muestra: ese res.cookie guarda la cookie en el host de la API. Eso basta cuando es la propia API la que va a leerla, en el mismo sitio que el frontend; si la API y el frontend están en dominios distintos, o si es el servidor del frontend el que necesita la cookie (como en una app Next.js), la API devuelve el token en una llamada de servidor a servidor y quien establece la cookie es el servidor del frontend, en su propio dominio.


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_verifier generado en el cliente, hasheado en un code_challenge enviado al inicio del flujo, y el code_verifier original 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 pruebas 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.

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