Saltar al contenido
< samuelsantana.dev />
Volver al BlogLas cinco etapas del login OAuth en una línea de diagnóstico, cada una con su error típico; el flujo se detiene en la etapa 3, OAUTH_STATE_MISMATCH, marcada con un sello rojo "AQUÍ".

Error al inicializar OAuth: cómo descubrir en qué etapa se rompió el login

Samuel Santana
Publicado el 03 de octubre de 2026
OAuthSecurityNext.js

"Error al inicializar OAuth", "No se pudo iniciar sesión", "Login failed". El mensaje que le llega al usuario casi nunca dice dónde se detuvo el flujo, y un login OAuth tiene al menos cinco lugares donde detenerse. Cada uno deja un rastro distinto, y el rastro aparece antes de leer una sola línea de código.

Este post es un mapa organizado por síntoma: dónde mirar, cómo probar la causa, qué corregir. Los ejemplos vienen del login con Google y GitHub de samuelsantana.dev, con código abierto en vertex-api y vertex-web. Uno de ellos es una corrección mía: hasta el 2 de octubre mi login no enviaba state ni PKCE.

Primero, descubre la etapa

En el flujo de authorization code, el login pasa por cinco etapas, y cada una falla a su manera:

EtapaDónde mirarCómo se ve la falla
1. La aplicación arma la URL de autorizaciónprimera petición del popup o de la pestañapágina de error del proveedor, o error en la consola
2. El proveedor autentica y redirige de vueltapágina del proveedor, o ?error= en el callbackredirect_uri_mismatch, invalid_client, access_denied
3. El callback verifica el statela petición al callback y sus cookiesstate distinto o ausente
4. El backend canjea el code por tokenslog del servidor (el navegador no lo ve)invalid_grant, invalid_client
5. Se crea la sesión y se avisa a la interfazcookie de sesión, window.openersesión iniciada, interfaz sin enterarse

La herramienta es el panel Network de DevTools, abierto en el popup (es otra ventana, con su propio DevTools), con "Preserve log" marcado para sobrevivir a las redirecciones.

Un detalle que cuesta tiempo: el HAR que Chrome exporta por defecto está sanitizado y excluye Cookie, Set-Cookie y Authorization, justo los headers que explican un error de state. Para ese caso, lee la pestaña Cookies en vivo.

Etapa 1: la URL de autorización ya sale mal

Empieza leyendo la URL que el navegador recibió para ir al proveedor. En samuelsantana.dev, el popup abre una ruta de la API, y la API responde con la redirección:

$ curl -sI https://api.samuelsantana.dev/auth/google
HTTP/1.1 302 Found
cross-origin-opener-policy: same-origin-allow-popups
Set-Cookie: oauth_state_google=<state>.<verifier>.<firma>; Max-Age=600; Path=/auth/google/callback; HttpOnly; Secure; SameSite=Lax
location: https://accounts.google.com/o/oauth2/v2/auth?code_challenge=YiKK...&code_challenge_method=S256&response_type=code&redirect_uri=https%3A%2F%2Fapi.samuelsantana.dev%2Fauth%2Fgoogle%2Fcallback&scope=email%20profile&state=kxzm...&client_id=<client-id>

Esa respuesta resuelve la mitad de las preguntas: client_id presente, redirect_uri absoluto y con https, state, y code_challenge con S256. Los dos headers de arriba vuelven en las etapas 3 y 5.

client_id=undefined y la variable que no estaba en el build

Si la URL se arma en el navegador de una app Next.js, el valor viene de una variable NEXT_PUBLIC_*, que se incrusta en el JavaScript en el momento del next build. La documentación es explícita: después del build, la app "ya no responde a cambios en estas variables". Cambiar el valor en el panel del hosting sin un build nuevo no cambia nada. Y solo se sustituye la referencia literal: process.env[nombre] y const env = process.env; env.NEXT_PUBLIC_X quedan fuera.

El síntoma depende de lo que el código haga con el valor ausente. Una URL con client_id=undefined termina en la página de error de Google. Mi código tiene la variante silenciosa:

// LoginModal.tsx (vertex-web)
const API_URL = process.env.NEXT_PUBLIC_VERTEX_API_URL ?? "http://localhost:3333";

El fallback existe para desarrollo. El precio es que un build sin la variable no falla: publica un popup que abre localhost:3333 en la máquina del visitante, y el síntoma pasa a ser "no se puede acceder a este sitio", sin una palabra sobre OAuth. A favor de este diseño: el client_id nunca entra en el bundle del navegador, porque quien arma la URL del proveedor es la API.

La biblioteca que falla al inicializar

Si el código usa la biblioteca antigua de Google (platform.js, gapi.auth2), su error de inicialización es idpiframe_initialization_failed: "no se pudo inicializar un iframe necesario de Google". Está obsoleta desde el 31 de marzo de 2023, y los client IDs creados después del 29 de julio de 2022 no pueden usarla. La salida es Google Identity Services, donde la falta de client_id o scope produce un error en la consola y los errores fuera del protocolo llegan por error_callback (popup_failed_to_open, popup_closed). En Auth.js, el equivalente es OAuthSignInError: el login por OAuth "no pudo iniciarse".

Etapa 2: el proveedor rechaza

La RFC 6749 divide los errores de esta etapa en dos grupos, y la división ya es un diagnóstico. Si el problema es el redirect_uri o el client_id, el proveedor no debe redirigir de vuelta: el usuario se queda en una página de error del proveedor y tu servidor nunca se entera. Los demás errores vuelven al callback como ?error=...&state=.... Un log de callback vacío apunta al primer grupo.

redirect_uri_mismatch

La RFC 9700, la guía de buenas prácticas de seguridad de OAuth publicada en enero de 2025, exige comparación exacta de cadenas entre el redirect_uri recibido y el registrado. La pregunta, entonces, no es "¿se parece?", es "¿qué byte es distinto?": http frente a https, barra final, www, puerto, prefijo de idioma en la ruta.

Google esconde la respuesta en la URL de su propia página de error, en un parámetro authError. Es base64url de una estructura binaria, sin formato documentado, pero legible:

# Valor de authError copiado de la barra de direcciones de la página de error de Google
node -e "console.log(Buffer.from(process.argv[1], 'base64url').toString('utf8'))" 'ChVyZWRpcmVjdF91cmlf...'

En una prueba con el client_id de mi sitio y un redirect_uri equivocado a propósito, la salida trajo el código redirect_uri_mismatch, el mensaje para el usuario, el enlace a la documentación del error y lo más útil: el redirect_uri que Google recibió, https://example.com/callback. Ese es el valor que hay que comparar, byte a byte, con el de la Consola. Si el código arma el redirect_uri a partir de la petición y hay un proxy terminando TLS delante, es aquí donde aparece el http.

invalid_client, deleted_client y access_denied

Con un client_id que no existe, el authError decodificado trae invalid_client y "The OAuth client was not found". La lista oficial de Google tiene un pariente menos obvio, deleted_client: clientes borrados a mano o "automáticamente, en el caso de clientes sin uso". Un proyecto de demostración olvidado puede perder el login sin que nadie lo toque.

access_denied es el usuario o el servidor de autorización denegando la solicitud. Vuelve al callback, y el callback tiene que tratar error antes de buscar code. Es lo primero que verifica el guard de vertex-api, y un error no toca la cookie de state, porque una respuesta de error no prueba quién la envió.

En Google, el rechazo también viene del modo de prueba: una app con estado "Testing" está limitada a hasta 100 usuarios de prueba listados. En ese estado, el refresh token expira en 7 días, salvo que los únicos scopes sean nombre, correo y perfil. Una app de solo login se salva; una app que pide Google Drive pierde el acceso offline después de una semana, sin ningún error en el login.

Etapa 3: el callback rechaza el state

Sin state, un atacante lleva el navegador de la víctima a tu callback con el authorization code del atacante, y la víctima termina con la sesión iniciada en la cuenta del atacante. Es el ataque que describe la RFC 6749. La RFC 9700 obliga al cliente a impedirlo: si el proveedor soporta PKCE de forma comprobada, el cliente puede apoyarse en él; en OpenID Connect, el nonce cumple ese papel; fuera de eso, es obligatorio un token de un solo uso en el state, vinculado al navegador que empezó el flujo.

Yo no tenía ninguno de los dos. Hasta el vertex-api #57, el 2 de octubre, el callback aceptaba cualquier code. Ahora la API genera state y verifier, guarda los dos en una cookie firmada y los verifica a la vuelta:

// oauth-state.util.ts (vertex-api), simplificado
if (query.error) {
  return {}; // el proveedor rechazó; no hay code que verificar
}

if (query.code) {
  const attempt = takeOAuthAttempt(request, reply, provider); // lee y borra la cookie
  if (!attempt || !statesMatch(attempt.state, query.state)) {
    throw new OAuthStateMismatchException(); // antes de passport: el code nunca se canjea
  }
  return { codeVerifier: attempt.codeVerifier };
}

const attempt = createOAuthAttempt(); // 32 bytes aleatorios para state y verifier
rememberOAuthAttempt(reply, provider, attempt);
return { state: attempt.state, codeChallenge: codeChallengeFor(attempt.codeVerifier) };

La comparación usa timingSafeEqual, y la cookie se borra al leerla, así que un intento vale para un solo callback. El código completo justifica cada atributo de la cookie. Las formas en que el state no coincide:

Expiró. La cookie vive 10 minutos (Max-Age=600).

Otra pestaña, o dos clics. Hay una cookie por proveedor. El segundo intento sobrescribe el primero, y el callback del primero compara su state con el del segundo. De ahí el mensaje del sitio para este error: "El inicio de sesión expiró o se inició en otra pestaña. Inténtalo de nuevo."

La cookie no se envió. El grupo grande. El culpable es un atributo del Set-Cookie de la etapa 1:

  • SameSite=Strict: la vuelta del proveedor es una navegación desde otro sitio, y las cookies Strict solo acompañan peticiones originadas en el mismo sitio. Lax se envía en navegaciones de nivel superior con método seguro, como el GET de la redirección. Por eso vertex-api usa Lax.
  • response_mode=form_post: la vuelta es un POST, y Lax no se envía en POST entre sitios. Cuando el navegador aplica Lax por defecto (la cookie no declaró SameSite), MDN registra una excepción: el POST todavía lleva la cookie si se guardó hace no más de dos minutos. Quien consiente rápido entra; quien tarda, no. Google ofrece form_post en su documento de discovery; con él, la cookie necesita SameSite=None; Secure.
  • Path: la cookie de vertex-api tiene Path=/auth/google/callback y, por las reglas del atributo, no se envía a /en/auth/google/callback. Un middleware de idioma que redirige el callback tumba el state. En julio, vertex-web llegó a sacar /auth del middleware de next-intl para que un visitante en inglés no fuera de /auth/google a /en/auth/google (fc59b9e).
  • Host: sin Domain, la cookie es host-only. Empezar en ejemplo.com y volver en www.ejemplo.com falla.
  • Iframe: dentro de un iframe de otro sitio, la cookie es de terceros. Firefox y Safari la aíslan o restringen por defecto. Chrome no bloquea por defecto, solo en modo incógnito o por elección del usuario, y en octubre de 2025 Google confirmó que mantiene ese enfoque. Un login dentro de un iframe funciona o no según el navegador de quien hace clic.

La firma cambió. La cookie está firmada con COOKIE_SECRET. Cambiar el secreto entre la ida y la vuelta da el mismo error.

La prueba es siempre la pestaña Cookies de la petición al callback. Si oauth_state_* no está, vuelve a la redirección de la etapa 1, busca el Set-Cookie y pasa el ratón por el ícono de información: Chrome muestra el motivo del bloqueo. En Auth.js, toda esta familia aparece como InvalidCheck, "no se pudo realizar una verificación de PKCE, state o nonce", y la propia documentación apunta a un proveedor mal configurado o a un navegador que bloquea cookies.

Etapa 4: el canje del código falla

Esta etapa es de servidor a servidor. El navegador no ve nada; la respuesta del endpoint de token está en tu log, o en ninguna parte.

invalid_grant e invalid_client

Según la RFC 6749, aquí cabe un code inválido, expirado, revocado, emitido para otro cliente o que no corresponde al redirect_uri de la autorización. En la práctica, cuatro preguntas:

¿Se usó el code dos veces? Es de un solo uso, con vida máxima recomendada de 10 minutos, y un segundo uso debe rechazarse. GitHub responde bad_verification_code para un code incorrecto o expirado, y los suyos valen 10 minutos. Una forma de gastarlo dos veces sin darse cuenta es canjear el code dentro de un useEffect: en desarrollo, Strict Mode ejecuta un ciclo extra de setup y cleanup en cada Effect, y en el App Router viene activado por defecto. La segunda llamada lleva el mismo code. En producción el ciclo extra no existe, y el bug es solo de desarrollo.

¿El redirect_uri del canje es el mismo de la autorización? La RFC exige valores idénticos.

¿El verifier de PKCE es el correcto? Si el hash del code_verifier no coincide con el code_challenge de la ida, la respuesta es invalid_grant. Pasa cuando el verifier se genera de nuevo a la vuelta, o se quedó en la memoria de otro proceso. vertex-api lo guarda en la misma cookie firmada del state, así que el callback siempre tiene el verifier de ese intento, sin estado en el servidor. Google anuncia S256 en su discovery, y GitHub acepta PKCE desde el 14 de julio de 2025, solo con S256.

¿El estado del canje vive en la memoria de un solo proceso? Este es de mi código. La API no le entrega el token al navegador: entrega un código propio, de un solo uso y 60 segundos, que el frontend canjea de servidor a servidor (el motivo está en el post sobre el token en la URL). En el post de julio, vivía en un Map en memoria; con más de un proceso detrás de la URL, quien crea el código y quien lo gasta pueden caer en memorias distintas. Lo corregí antes de ver el síntoma, en el vertex-api #36: el código vive en Postgres, como SHA-256, y se consume con un DELETE ... RETURNING, que además da exactamente un ganador a dos canjes simultáneos.

En cambio, invalid_client en el canje es el secreto, no el ID: "el client secret es incorrecto", en palabras de Google, o incorrect_client_credentials en GitHub.

El ID token y el reloj

Quien valida el ID token de OpenID Connect por su cuenta verifica iss, aud, exp y nonce, y la especificación admite "un pequeño margen, normalmente no más de unos minutos" para la diferencia de reloj. Un servidor atrasado ve un token del futuro. Lo medí con jose 6.2.3, con iat y nbf 90 segundos adelante:

await jwtVerify(token, publicKey, { issuer, audience, clockTolerance: 0 });
// ERR_JWT_CLAIM_VALIDATION_FAILED: "nbf" claim timestamp check failed

await jwtVerify(token, publicKey, { issuer, audience, clockTolerance: 120 });
// ok

Sin nbf, solo con el iat adelantado, la misma llamada pasó con clockTolerance: 0. El margen debe ser pequeño y explícito; la corrección de verdad es sincronizar el reloj del servidor.

Etapa 5: el login ocurrió y la interfaz no lo sabe

Si la API guarda la cookie de sesión en su propio dominio y el frontend vive en otro, el frontend nunca la ve. Ese fue el bug del post de julio; la elección entre cookie de sesión y JWT está en el post sobre JWT y sesiones. El caso que merece espacio aquí es otro.

El popup que pierde su ventana de origen

Síntomas: window.opener es null en el callback, el postMessage a la ventana de origen nunca llega y, en la ventana de origen, popup.closed pasa a true con el popup todavía en pantalla. Un código que espera a que el popup se cierre para concluir "el usuario desistió" lo concluye en el primer segundo.

La causa es Cross-Origin-Opener-Policy, y el primer sospechoso suele ser el proveedor. El 2 de octubre, con Playwright en Chromium, puse a prueba esa sospecha: un popup abierto directamente en Google o GitHub mantuvo su opener: Google envía solo Cross-Origin-Opener-Policy-Report-Only, que reporta y no aplica, y GitHub no envía COOP. El mismo popup abierto por la ruta /auth/* de mi API perdió su opener. El corte venía del primer header de la etapa 1, same-origin-allow-popups, que el Helmet de vertex-api aplica en cada respuesta (el valor por defecto de Helmet es same-origin).

MDN lo explica. La página del blog no envía COOP, lo que equivale a unsafe-none, y un documento con unsafe-none siempre abre documentos con cualquier otro valor en un nuevo grupo de contextos de navegación. same-origin-allow-popups conserva las referencias de una página a los popups que ella abre; no hace nada por la ventana de origen de un documento que es el popup. El popup cambia de grupo en la primera redirección, antes de llegar al proveedor, y nada después restaura la referencia.

La salida fue dejar de depender de opener. El callback y la ventana de origen están en el mismo origen, y un BroadcastChannel tiene alcance por origen, no por referencia entre ventanas:

// En el callback, dentro del popup
new BroadcastChannel("vertex-oauth").postMessage("oauth-success");
window.close();

// En la ventana de origen, montado una vez
const channel = new BroadcastChannel("vertex-oauth");
channel.onmessage = (event) => {
  if (event.data === "oauth-success") refreshSession();
};

Según la tabla de MDN, unsafe-none en las rutas /auth/* debería mantener el opener. No lo probé, y no lo necesito: el canal funciona con o sin él. El razonamiento completo está en un comentario de vertex-web, corregido en el #96.

Mix-up, el error que no se ve

Un cliente que habla con más de un proveedor puede ser llevado a tratar la respuesta de uno como si viniera del otro. La RFC 9700 hace obligatoria una defensa: de preferencia el parámetro iss de la RFC 9207, que identifica al emisor en la propia respuesta; a falta de él, un redirect_uri distinto por proveedor. Google anuncia authorization_response_iss_parameter_supported: true en su discovery, pero vertex-api no lee el iss: usa un callback por proveedor y una cookie de state atada al Path de cada uno.

Por qué importa

Para quien hace clic, todas estas fallas son una sola: "error al inicializar OAuth". Para quien mantiene el sistema, son más de una docena de problemas, cada uno con su prueba. Fundirlos todos en un mensaje genérico deja al usuario sin saber qué intentar, y quien investiga empieza de cero.

La regla que sigo ahora: cada falla sale de la API con un código legible por máquina, y la traducción ocurre en el borde. Cuando el state no coincide, la API redirige el popup a /auth/callback?oauth_error=OAUTH_STATE_MISMATCH, el callback reenvía el código por el BroadcastChannel, y la ventana de origen lo traduce al idioma del visitante (vertex-web #99): "El inicio de sesión expiró o se inició en otra pestaña. Inténtalo de nuevo." El mensaje le dice al usuario qué hacer; el código me dice en qué etapa buscar.

Antes de tocar código por un error de OAuth, la primera pregunta es en cuál de las cinco etapas se detuvo. La respuesta casi siempre está en una URL o en un Set-Cookie, y casi nunca donde apareció el mensaje.

Referencias

Comentarios

Cargando comentarios...