🎯 Meta: cerrar el círculo backend ↔ frontend con el tema donde más errores graves se cometen: quién eres (autenticación) y qué puedes hacer (autorización), de punta a punta. Sesiones vs JWT, dónde guardar tokens SIN abrir agujeros, OAuth2/OIDC ("login con Google"), refresh tokens y passkeys.
Requisitos: apéndice C (hashing, JWT básico), caps. 15-19 (frontend). Los ejemplos usan NestJS + React/Next, pero los conceptos valen para cualquier stack del libro.
⚠️ Regla cero: la autenticación NO se inventa. Usa las librerías maduras de tu framework (Laravel Sanctum/Fortify, Auth.js, Passport, Spring Security…) o un proveedor (Keycloak, Auth0, Clerk). Este capítulo te enseña cómo funcionan por dentro para usarlas bien y depurarlas — no para que escribas tu propio crypto.
20.1 · El mapa completo (y las dos preguntas)
AUTENTICACIÓN: ¿quién eres? AUTORIZACIÓN: ¿qué puedes hacer?
│ │
▼ ▼
login (password, OAuth, roles / permisos / ownership
passkey, magic link) (se comprueba EN CADA petición,
│ SIEMPRE en el backend)
▼
el servidor te da una CREDENCIAL DE SESIÓN:
│
├── Cookie de sesión (id opaco + estado en servidor) ← la clásica
└── Token JWT (autocontenido, sin estado) ← la de APIs/SPALa pregunta que define TODO tu diseño: ¿dónde vive la sesión después del login?
| Sesión en servidor (cookie) | JWT (stateless) | |
|---|---|---|
| Qué viaja | Un id opaco (sess_a8f3…) | El token entero (claims firmados) |
| Estado | En Redis/BD del servidor | En ningún sitio (autocontenido) |
| Revocar | Trivial: borras la sesión | Difícil: vive hasta que expira |
| Escalar horizontal | Necesita store compartido (Redis, ap. B) | Gratis: cualquier nodo lo verifica |
| Ideal para | Webs con backend propio (HTMX, Next, Laravel) | APIs consumidas por terceros, microservicios, móvil |
🧠 La verdad incómoda que los tutoriales no cuentan: para una web con SU PROPIO backend (que es el 90% de los proyectos), la cookie de sesión httpOnly es más simple Y más segura que JWT en el navegador. JWT brilla entre servicios y en APIs públicas. "JWT porque es lo moderno" es el error de arquitectura de auth más repetido.
20.2 · Dónde guardar la credencial en el navegador (el debate eterno)
| Almacén | ¿Lo lee JavaScript? | Riesgo principal | Veredicto |
|---|---|---|---|
localStorage | ✅ Sí | XSS roba el token y lo exfiltra | ❌ Evítalo para credenciales |
| Variable en memoria JS | ✅ Sí (pero muere al refrescar) | XSS también, pero no persiste | 🟡 Aceptable para access token corto |
Cookie httpOnly | ❌ No | CSRF (mitigable con SameSite) | ✅ El estándar recomendado |
La cookie de sesión bien configurada:
Set-Cookie: sesion=abc123;
HttpOnly; ← JavaScript NO puede leerla (inmune a robo por XSS)
Secure; ← solo viaja por HTTPS
SameSite=Lax; ← no se envía en peticiones cross-site (mata el CSRF clásico)
Path=/;
Max-Age=604800 ← 7 días⚠️ XSS es el jefe final: si un atacante ejecuta JS en tu página, con
localStoragese lleva el token a su casa; con cookie httpOnly "solo" puede actuar mientras la víctima tiene la pestaña abierta. Por eso: httpOnly + todo lo del apéndice C (escapado, CSP) + nuncainnerHTML/v-htmlcon datos de usuario (caps. 15-19).⚠️ CSRF no ha muerto:
SameSite=Laxcubre el caso clásico, pero si tu API acepta peticiones cross-origin con cookies (credentials: 'include'), necesitas token CSRF o verificar el headerOrigin. Los frameworks lo traen hecho (Laravel, Django, NestJS + csurf).
20.3 · Flujo 1: cookie de sesión (HTMX, Next.js, Laravel — la web clásica)
El flujo más simple y robusto. Backend NestJS con sesión en Redis:
// auth.controller.ts (NestJS + express-session + connect-redis)
@Post('login')
async login(@Body() dto: LoginDto, @Req() req: Request) {
const user = await this.auth.validar(dto.email, dto.password); // argon2.verify
if (!user) throw new UnauthorizedException('Credenciales inválidas'); // mensaje GENÉRICO
req.session.userId = user.id; // ← esto setea la cookie httpOnly sola
return { id: user.id, nombre: user.nombre };
}
@Post('logout')
logout(@Req() req: Request) {
req.session.destroy(() => {}); // revocación instantánea ✅
}
@Get('me')
@UseGuards(SessionGuard) // lee req.session.userId, carga el user
me(@CurrentUser() user: User) {
return user;
}Frontend React — el patrón me() al arrancar:
// El frontend NUNCA ve la credencial: solo pregunta "¿quién soy?"
function useSesion() {
return useQuery({
queryKey: ['me'],
queryFn: async () => {
const res = await fetch('/api/me', { credentials: 'include' });
if (res.status === 401) return null; // no logueado: estado válido, no error
if (!res.ok) throw new Error('Error de red');
return res.json() as Promise<Usuario>;
},
staleTime: 5 * 60_000,
retry: false,
});
}
// Ruta protegida (React Router)
function RutaPrivada({ children }: { children: ReactNode }) {
const { data: user, isPending } = useSesion();
if (isPending) return <Spinner />;
if (!user) return <Navigate to="/login" replace />;
return children;
}🧠 Ocultar un botón NO es autorización. El frontend esconde UI por UX; el backend rechaza por seguridad. Cualquiera puede llamar a tu API con curl saltándose tu React. Cada endpoint valida sesión + permisos aunque "el botón no se vea".
Con HTMX este flujo es aún más natural: el login es un <form hx-post="/login">, el servidor setea la cookie y responde HX-Redirect: /panel. Cero JavaScript de auth.
20.4 · Flujo 2: JWT access + refresh (SPA contra API separada)
Cuando frontend y API son servicios separados (o hay app móvil), el patrón correcto es doble token:
┌────────────┐ POST /login ┌────────────┐
│ Frontend │────────────────────────────────────▶│ API │
│ │◀─ access token (JWT, 10-15 min) ───│ │
│ access → │ en el BODY (vive en memoria JS) │ │
│ memoria │◀─ refresh token (días) ───│ refresh → │
│ │ en COOKIE httpOnly │ BD (hash) │
└────────────┘ └────────────┘
cada petición: Authorization: Bearer <access>
cuando el access caduca (401) → POST /refresh (la cookie viaja sola)
→ nuevo access + NUEVO refresh (rotación) → reintenta la peticiónPor qué así y no de otra forma:
- Access corto en memoria: si XSS lo roba, caduca en minutos. Nunca en
localStorage. - Refresh en cookie httpOnly y guardado (hasheado) en BD: puedes revocarlo (logout, robo, "cerrar sesión en todos los dispositivos"). El JWT recupera así lo que perdía: la revocación.
- Rotación: cada refresh emite un refresh nuevo e invalida el anterior. Si alguien reusa uno viejo → familia entera revocada (detección de robo).
El interceptor del frontend (la parte que todos hacen mal):
// api/cliente.ts — reintento transparente en 401, sin estampida de refreshes
let accessToken: string | null = null;
let refrescando: Promise<string> | null = null;
async function refrescar(): Promise<string> {
// deduplica: si 5 peticiones reciben 401 a la vez, UN solo /refresh
refrescando ??= fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' })
.then(async (r) => {
if (!r.ok) { window.location.href = '/login'; throw new Error('Sesión caducada'); }
const { access } = await r.json();
accessToken = access;
return access;
})
.finally(() => (refrescando = null));
return refrescando;
}
export async function api(ruta: string, init: RequestInit = {}): Promise<Response> {
const conAuth = (token: string | null): RequestInit => ({
...init,
headers: { ...init.headers, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
});
let res = await fetch(`/api${ruta}`, conAuth(accessToken));
if (res.status === 401) {
const nuevo = await refrescar();
res = await fetch(`/api${ruta}`, conAuth(nuevo)); // reintenta UNA vez
}
return res;
}Claims mínimos del access token y su verificación (repaso del apéndice C):
{ "sub": "42", "rol": "admin", "iat": 1752144000, "exp": 1752144900, "iss": "api.mitienda.com" }⚠️ Verifica firma +
exp+iss/auden cada petición y rechazaalg: none. Y nunca metas datos sensibles en el payload: un JWT se decodifica (base64), solo la firma impide modificarlo.
20.5 · OAuth 2.0 / OIDC: "iniciar sesión con Google"
OAuth2 delega la autenticación en un proveedor (Google, GitHub…). El único flujo que debes usar en 2026: Authorization Code + PKCE.
1. Usuario pulsa "Login con Google"
2. Frontend → redirige a Google con: client_id, redirect_uri,
scope=openid email profile, state (anti-CSRF), code_challenge (PKCE)
3. Usuario se autentica EN GOOGLE (tu app jamás ve su contraseña)
4. Google redirige a tu redirect_uri con ?code=XYZ&state=...
5. TU BACKEND cambia el code por tokens (con client_secret + code_verifier)
← este paso SIEMPRE en el servidor: el secret nunca pisa el navegador
6. Backend valida el id_token (OIDC), busca/crea el usuario en TU BD
7. Backend crea TU PROPIA sesión (cookie httpOnly, como en 20.3)🧠 Los tokens de Google no son tu sesión. Sirven para identificar al usuario UNA vez (paso 6). Después, tu app usa su propio mecanismo (sesión o tus JWT). Mezclar ambos es el lío conceptual nº 1 con OAuth.
⚠️ Verifica
state(anti-CSRF del flujo) y registraredirect_uriexactas en el proveedor. Y en el backend valida elid_token: firma contra las claves públicas (JWKS) del proveedor,aud= tu client_id,isscorrecto.
En la práctica no lo montas a mano: Auth.js (Next), Socialite (Laravel), Passport (NestJS), Authlib (Python), o autohospedas Keycloak/Zitadel si quieres tu propio proveedor OIDC para varios servicios.
// Next.js con Auth.js v5 — todo el flujo anterior en 10 líneas
// auth.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [Google], // lee GOOGLE_CLIENT_ID/SECRET del entorno
callbacks: {
session: async ({ session, token }) => ({ ...session, userId: token.sub }),
},
});
// En un Server Component o Server Action:
const session = await auth();
if (!session) redirect('/login');20.6 · Passkeys (WebAuthn): el futuro ya presente
Las passkeys sustituyen la contraseña por un par de claves: la privada vive en tu dispositivo (desbloqueada con huella/cara) y la pública en el servidor. No hay nada que robar en un phishing — la credencial está atada al dominio.
Registro: navegador crea par de claves para "mitienda.com"
→ servidor guarda la clave PÚBLICA
Login: servidor envía un reto → dispositivo lo firma con la privada
(tras Face ID / huella) → servidor verifica con la pública// El API del navegador (simplificado — usa una librería: @simplewebauthn/browser+server)
const credencial = await navigator.credentials.create({ publicKey: opcionesDelServidor });
// login:
const firma = await navigator.credentials.get({ publicKey: retoDelServidor });Estrategia 2026 realista: passkey como método preferente + password u OAuth como respaldo. Las librerías simplewebauthn (JS), webauthn4j (Java) o el soporte nativo de Laravel Fortify te dan el flujo completo.
20.7 · Autorización: roles, permisos y ownership
Tres niveles, de simple a fino:
// 1. ROL — grueso (admin, editor, cliente)
@UseGuards(AuthGuard, RolesGuard)
@Roles('admin')
@Delete('productos/:id')
eliminar(...) {}
// 2. PERMISO — medio ("productos.borrar") : roles → permisos en BD, checa permisos
// 3. OWNERSHIP — fino: ¿este recurso ES TUYO?
async borrarPedido(userId: number, pedidoId: number) {
const pedido = await this.repo.buscar(pedidoId);
if (!pedido) throw new NotFoundException();
if (pedido.userId !== userId) throw new ForbiddenException(); // ← anti-IDOR (ap. C)
await this.repo.borrar(pedidoId);
}⚠️ El olvido del ownership es el IDOR del apéndice C:
GET /pedidos/123sin comprobar que el pedido 123 es del usuario logueado = cualquier cliente lee pedidos ajenos cambiando el número. Es la vulnerabilidad nº 1 de APIs reales (OWASP API Top 10 #1).
En el frontend, refleja (no sustituyas) estos permisos:
{user.permisos.includes('productos.borrar') && <BotonBorrar id={p.id} />}20.8 · Checklist y errores mortales
Checklist del login (repaso ap. C + nuevo):
Errores que se ven cada semana en producción:
| Error | Consecuencia |
|---|---|
JWT en localStorage | Un XSS = cuenta robada persistentemente |
| Access token de 24 h "para no molestar" | Robo = 24 h de acceso irrevocable |
| Validar el rol solo en el frontend | curl se salta tu React |
client_secret de OAuth en el código del frontend | Cualquiera suplanta tu app |
| Sin ownership check (solo "está logueado") | IDOR masivo |
| Implementar tu propio hash/firma "sencillito" | Todo lo anterior junto |
✅ Ejercicio del capítulo
Añade auth completa a tu tienda (backend del cap. 03-08 + frontend del 17/18/19):
1. Registro + login con argon2id y sesión en cookie httpOnly (Redis como store).
2. Endpoint /me; en el frontend, useSesion() + <RutaPrivada> y layout con estado logueado.
3. Versión B del mismo login con access (15 min, en memoria) + refresh rotativo
(cookie httpOnly + hash en BD) y el interceptor con deduplicación de refresh.
4. "Login con GitHub" (OAuth code + PKCE) usando la librería de tu framework;
al volver, crea el usuario local y tu propia sesión.
5. Roles admin/cliente: el borrado de productos exige admin EN EL BACKEND;
el botón solo se muestra a admins en el frontend.
6. Ownership: /mis-pedidos/:id devuelve 403 si el pedido no es tuyo (test incluido).
7. Rate limit en /login (5 intentos/min) y test que lo verifica.
8. Bonus: passkey de registro/login con @simplewebauthn.💡 Pistas de la solución (abre solo si te atascas)
- NestJS:
express-session+connect-redis; el guard leereq.session.userId. Laravel: Sanctum en modo SPA hace la cookie httpOnly + CSRF por ti. - El error típico del punto 3: guardar el refresh sin hashear. Guárdalo como
sha256(refresh)— si te roban la BD, no les sirven. - Para el interceptor, el test clave: dispara 3 peticiones con access caducado y verifica con un spy que
/refreshse llamó una sola vez. - OAuth en local: registra
http://localhost:5173/callbackcomo redirect URI exacta. - El test de ownership: crea 2 usuarios, el pedido con el 1º, pide con el token del 2º, espera 403 (no 404 si quieres distinguir; 404 si prefieres no revelar existencia).
🧠 Autoevaluación
Porque JavaScript no puede leerla: un script inyectado no puede exfiltrar la credencial y usarla desde otra máquina. Con localStorage, el XSS se lleva el token y la sesión queda robada de forma persistente. (Ojo: httpOnly no impide el XSS, limita su botín.)
La revocación. Un JWT puro es válido hasta que expira, pase lo que pase. Con access corto + refresh revocable en BD, cerrar sesión (o detectar un robo) invalida el refresh y el atacante pierde el acceso en ≤ 15 minutos.
Porque requiere el client_secret, y todo lo que llega al navegador es público (cap. 15.5). Si el secret se filtra, cualquiera puede hacerse pasar por tu aplicación ante Google.
No: falta ownership. Estar autenticado ≠ estar autorizado. Sin comprobar que el pedido pertenece al usuario, cualquier usuario logueado lee pedidos ajenos (IDOR, OWASP API #1).
La credencial está criptográficamente atada al dominio real: en mitienda-fake.com el navegador simplemente no encuentra ni usa la passkey de mitienda.com, y no hay contraseña que el usuario pueda teclear en el sitio falso.
Siguiente: 21-tiempo-real.md — WebSockets y SSE de punta a punta.