Skip to content

🎯 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/SPA

La 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é viajaUn id opaco (sess_a8f3…)El token entero (claims firmados)
EstadoEn Redis/BD del servidorEn ningún sitio (autocontenido)
RevocarTrivial: borras la sesiónDifícil: vive hasta que expira
Escalar horizontalNecesita store compartido (Redis, ap. B)Gratis: cualquier nodo lo verifica
Ideal paraWebs 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 principalVeredicto
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 httpOnlyNoCSRF (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 localStorage se 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) + nunca innerHTML/v-html con datos de usuario (caps. 15-19).

⚠️ CSRF no ha muerto: SameSite=Lax cubre el caso clásico, pero si tu API acepta peticiones cross-origin con cookies (credentials: 'include'), necesitas token CSRF o verificar el header Origin. Los frameworks lo traen hecho (Laravel, Django, NestJS + csurf).


El flujo más simple y robusto. Backend NestJS con sesión en Redis:

typescript
// 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:

tsx
// 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ón

Por 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):

typescript
// 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):

json
{ "sub": "42", "rol": "admin", "iat": 1752144000, "exp": 1752144900, "iss": "api.mitienda.com" }

⚠️ Verifica firma + exp + iss/aud en cada petición y rechaza alg: 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 registra redirect_uri exactas en el proveedor. Y en el backend valida el id_token: firma contra las claves públicas (JWKS) del proveedor, aud = tu client_id, iss correcto.

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.

typescript
// 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
javascript
// 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:

typescript
// 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/123 sin 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:

tsx
{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:

ErrorConsecuencia
JWT en localStorageUn XSS = cuenta robada persistentemente
Access token de 24 h "para no molestar"Robo = 24 h de acceso irrevocable
Validar el rol solo en el frontendcurl se salta tu React
client_secret de OAuth en el código del frontendCualquiera 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 lee req.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 /refresh se llamó una sola vez.
  • OAuth en local: registra http://localhost:5173/callback como 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.