OAuth Login, Sin la Sopa de Tokens

El login web se complica cuando las cookies de sesión, JWT, OAuth y OpenID Connect se tratan como sinónimos. No lo son. Este es el modelo que uso cuando un callback entra en bucle, una cookie desaparece o un token perfectamente válido sigue devolviendo un 401.

🎙️ Publicado y grabado: ·

01Sesión vs JWT: elige lo aburrido primero

Para un sitio web normal, empezaría con un ID de sesión opaco en una cookie HttpOnly. El servidor almacena la sesión y puede revocarla al instante. Un JWT es un paquete firmado de claims, útil cuando varios servicios deben verificar la misma credencial de corta vida sin un session store compartido. No es una cookie de sesión de lujo.

# Opaque session cookie: meaningless to the browser
Set-Cookie: __Host-session=s%3A8f1...; Path=/; Secure; HttpOnly; SameSite=Lax

# JWT payload is readable, not encrypted
{"sub":"user_42","aud":"api","exp":1784905200}
Mi default
Una app web y un backend: usa sesiones del lado del servidor. El logout "stateless" con JWT suele acabar con una lista de revocación, momento en el que reconstruiste el estado con peor ergonomía. Nunca pongas secretos en el payload de un JWT; base64 no es cifrado.

02Los cuatro roles de OAuth

OAuth es autorización delegada. El resource owner es el usuario. El client es tu app. El authorization server pide consentimiento y emite tokens. El resource server es la API que acepta esos tokens. "Client" no significa navegador, y el authorization server no tiene que alojar la API.

resource owner:       the person
client:               your photo-printing site
authorization server: accounts.example
resource server:      photos API
scope:                 permission such as photos.read

Los scopes describen lo que el client puede hacer, no lo que la persona puede hacer. Un token con photos.read sigue sin deber leer el álbum de otra persona. La API comprueba tanto scope como propiedad.

03OAuth no es un protocolo de autenticación

OAuth no le dice a tu app quién inició sesión. Delega acceso a algo. OpenID Connect añade la capa de identidad: un ID token, un endpoint UserInfo, metadata de descubrimiento y reglas para validar claims de identidad. "Iniciar sesión con…" debería usar OIDC, no inventar identidad consultando un endpoint de perfil OAuth.

# Access token: for the API; audience is the resource server
Authorization: Bearer <access_token>

# ID token: for the client; establishes the login event
iss  = https://accounts.example
aud  = your_client_id
sub  = stable-provider-user-id
nonce = value-bound-to-this-login
No uses email como clave de usuario
El email puede cambiar y puede ser reciclado. Almacena el par issuer + subject. Valida firma, issuer, audience, expiración y nonce antes de confiar en un ID token. Decodificar su JSON no es validación.

04Authorization Code más PKCE

El flujo práctico actual envía al navegador al authorization server, recibe un código de un solo uso de corta vida, e intercambia ese código por un canal trasero. PKCE vincula el intercambio a la instancia de la app que lo inició. La app crea un verifier aleatorio, envía su hash como challenge, y luego demuestra posesión enviando el verifier en el intercambio de token.

1. app stores code_verifier + state + nonce
2. /authorize?response_type=code&code_challenge=HASH&code_challenge_method=S256
3. callback?code=ONE_TIME_CODE&state=...
4. POST /token with code + code_verifier
5. validate ID token; create your own app session
Sáltate los atajos antiguos
No uses el flujo implícito para apps de navegador nuevas. No envíes un client secret en JavaScript; todo el mundo puede leerlo. PKCE protege un client público sin pretender que un navegador puede guardar un secreto.

05Los atributos de cookie deciden si el login persiste

HttpOnly impide que JavaScript lea la cookie. Secure la limita a HTTPS. SameSite=Lax es un default sensato para sitios web y sigue permitiendo un callback OAuth de nivel superior. El prefijo __Host- requiere Secure, Path=/ y sin Domain, lo que impide que un subdominio hermano plante esa cookie.

Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax

This Set-Cookie was blocked because it had the "SameSite=None"
attribute but did not have the "Secure" attribute.
# Fix production: add Secure and serve HTTPS.
# For local HTTP, prefer Lax on localhost; don't weaken production settings.

Si el login tiene éxito y la siguiente petición aparece deslogueada, inspecciona la respuesta del callback en DevTools del navegador. Comprueba si la cookie fue establecida, bloqueada, con scope al host incorrecto u omitida en la siguiente petición. Re-ejecutar OAuth no reparará un error de Domain o SameSite.

06Los refresh tokens son credenciales de alto valor

Los access tokens deben ser de corta vida. Un refresh token obtiene nuevos sin pedirle al usuario que inicie sesión de nuevo, lo que lo convierte en la credencial más valiosa. Mantenlo en un backend de confianza o en un diseño de cookie estrictamente protegido, rótalo en cada uso y revoca la familia de tokens cuando aparezca un token rotado antiguo.

POST /oauth/token
  grant_type=refresh_token
  refresh_token=<secret>
  client_id=<client>

response: access_token + new_refresh_token
store the new refresh token; invalidate the old one
No refresques para siempre
Establece un tiempo de vida absoluto de la sesión además de la expiración por inactividad. La rotación limita el replay; no convierte un token robado en inofensivo. En reset de contraseña, recuperación de cuenta o sospecha de robo, revoca las sesiones del lado del servidor y las familias de refresh-token.

07CSRF: el navegador envía cookies amablemente

CSRF funciona porque un navegador puede adjuntar la cookie de tu sitio a una petición iniciada por otro sitio. SameSite ayuda, pero los flujos de login también necesitan un valor state aleatorio vinculado a la sesión del navegador. En el callback, compáralo exactamente y consúmelo una vez. State no es decoración y no es la URL de retorno.

OAuthCallbackError: state mismatch
# Diagnose before retrying:
1. Was state stored before redirect?
2. Did the same browser/session return?
3. Did a proxy change host or scheme, losing the cookie?
4. Was the callback opened twice or state consumed early?

# Fix the session/cookie/proxy issue. Never skip state validation.
Rutas de retorno seguras
Si preservas "a dónde ir después del login", permite solo rutas locales o una lista explícita. Un next=https://attacker.example sin filtrar crea un open redirect que hace tu dominio de login de confianza útil para phishing.

08XSS cambia la decisión de almacenamiento

Un token en localStorage es legible por cualquier JavaScript que se ejecute en la página, incluyendo una dependencia comprometida o script inyectado. Una cookie HttpOnly oculta la credencial de JavaScript, aunque JavaScript malicioso puede seguir haciendo peticiones mientras la página está comprometida. No hay truco de almacenamiento que haga aceptable un XSS.

# Avoid this default for website login
localStorage.setItem("access_token", token)

# Prefer a backend-for-frontend session
browser --HttpOnly session cookie--> your backend
backend --access token--> provider/API
La defensa práctica
Escapa la salida no confiable, sanitiza HTML intencional, evita inyección de script inline, despliega una Content Security Policy restrictiva y audita dependencias. HttpOnly reduce el robo de tokens; no limpia una página comprometida.

09Errores de callback: compara bytes exactos

Los fallos de callback OAuth suelen ser precisos, no místicos. Los proveedores comparan redirect URIs exactamente. Scheme, host, puerto, path y a veces trailing slash deben coincidir con el valor registrado. Después de eso, los authorization codes son de corta vida, de un solo uso y vinculados al client, redirect URI y PKCE verifier.

Error 400: redirect_uri_mismatch
# Sent:       http://localhost:3000/auth/callback
# Registered: http://localhost:3000/auth/callback/
# Fix the registration or generated URI so they match exactly.

{"error":"invalid_grant","error_description":"Bad Request"}
# Common causes: code reused/expired, wrong code_verifier,
# wrong redirect_uri, clock skew, or code issued to another client.
Depura una vez, no con tormentas de reintentos
Registra un ID de correlación de petición, código de error del proveedor, URI de callback, client ID y timestamps. Nunca registres el code, verifier, tokens o client secret. Reiniciar el flujo está bien para un code expirado; no arreglará un URI mismatch determinista.

10Un playbook para el token 401

Un 401 después de un login exitoso no prueba que el login falló. Tu app puede estar enviando un ID token a una API, un access token para la audience incorrecta, un token expirado o nada en absoluto. Inspecciona lo que el resource server espera. Trata el contenido de los tokens como sensible incluso mientras depuras.

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token",
  error_description="The audience 'web-client' is invalid"

1. Is the Authorization header present and exactly "Bearer <token>"?
2. Is this an access token, not an ID token?
3. Do iss and aud match this API?
4. Are exp/nbf valid with modest clock skew?
5. Does the API trust the token's signing key and algorithm?
6. If valid but disallowed, that is normally 403: inspect scopes/roles.
Arquitectura final
Usa OIDC para establecer identidad, luego crea tu propia sesión de aplicación. Mantén los access y refresh tokens del proveedor en el servidor a menos que el navegador realmente deba llamar a ese proveedor directamente. Para un sitio web ordinario, cookies aburridas más un backend son más fáciles de revocar, auditar y explicar a las 3 de la madrugada.

Ese es todo el modelo funcional: OAuth delega acceso, OIDC proporciona identidad, cookies llevan tu sesión de aplicación, y cada token tiene una audience prevista. Mezclar esos trabajos es donde empiezan los bugs.

Tell me what missed

A correction is more useful than a compliment. This goes straight to the person who writes SwiftGrasp.

Was this page useful?
0/1000

Please do not include passwords, private keys, or personal information.