Autenticar una aplicación Angular con JWT es una de esas tareas que cualquiera ha visto resuelta en diez tutoriales distintos, casi todos con la misma receta: guardar el token en localStorage, añadirlo en un interceptor y poner un guard. Funciona, pero el Angular actual ya no se escribe así y buena parte de esas guías pasan por alto la decisión que de verdad importa, que es dónde vive el token y qué pasa cuando alguien consigue ejecutar JavaScript en tu página.
Este artículo monta un flujo completo con las herramientas de Angular 22 (la rama estable en el momento de escribirlo, con la 22.2 publicada a finales de octubre de 2026): componentes standalone, provideHttpClient con interceptores funcionales, guards como funciones, el estado de sesión en signals y renovación automática del token. Al final comparamos sin maquillaje las opciones de almacenamiento. Si necesitas repasar la base del framework, tienes antes TypeScript con Angular: decoradores, DI tipado, HttpClient y señales.
Qué ha cambiado en Angular desde los tutoriales antiguos
Conviene fijar el punto de partida, porque varias cosas que se daban por hechas ya no están:
- HttpClient viene incluido. Desde Angular 21 se puede inyectar sin configurar nada;
provideHttpClientsolo hace falta para cambiar sus características, por ejemplo registrar interceptores.HttpClientModuleestá marcado como obsoleto. - El backend por defecto es
fetch. Si necesitas eventos de progreso de subida, la documentación indica pasar awithXhr(). - Los interceptores son funciones. El tipo
HttpInterceptorFnrecibe la petición y unnextde tipoHttpHandlerFn, y se ejecuta en contexto de inyección, así que puedes llamar ainject()dentro. Se registran conwithInterceptors([...])y se encadenan en el orden de la lista. - Los guards también son funciones (
CanActivateFn,CanMatchFn) y para redirigir se devuelve unUrlTreeo unRedirectCommand. La propia documentación avisa de que no se debe devolverfalsey navegar a mano después. - La inicialización de la app usa
provideAppInitializer, que sustituye al tokenAPP_INITIALIZER, también obsoleto.
El diseño: access token corto en memoria y refresh token en cookie
La arquitectura que desarrollamos reparte el trabajo entre dos credenciales con vidas y lugares de almacenamiento distintos:
Credencial | Vida | Dónde vive | Quién la lee |
Access token (JWT) | Minutos (10 en el ejemplo) | Variable en memoria (un signal) | El interceptor, que lo manda en |
Refresh token (opaco) | Días (7 en el ejemplo) | Cookie | Solo el servidor, en |
El access token es un JWT firmado que la API valida sin consultar ninguna base de datos. Como no se puede revocar antes de que caduque, se mantiene de vida corta. El refresh token es una cadena aleatoria sin significado, se guarda hasheado en el servidor y se rota en cada uso, de modo que sí se puede revocar. El navegador lo adjunta él solo y JavaScript no puede leerlo.
El contrato de la API que usaremos es este, con todas las rutas bajo el mismo origen que la aplicación (por ejemplo con un proxy inverso que sirve Angular en / y la API en /api):
Endpoint | Entrada | Respuesta |
| JSON con |
|
| Solo la cookie |
|
| Solo la cookie |
|
| Cabecera | Datos del usuario o |
Backend mínimo en Express
Para poder probar el cliente de punta a punta basta un servidor pequeño. No es código listo para producción (el almacén de refresh tokens es un Map en memoria y la verificación de credenciales queda como función por rellenar), pero contiene las decisiones que importan: algoritmo de firma fijado en la verificación, refresh token opaco y hasheado, rotación en cada uso y cookie con todos los atributos de seguridad.
// server.mjs · Node 20+, Express 5
import express from 'express';
import cookieParser from 'cookie-parser';
import jwt from 'jsonwebtoken';
import { createHash, randomBytes } from 'node:crypto';
const ACCESS_SECRET = process.env.JWT_SECRET; // 32 bytes aleatorios como mínimo
const REFRESH_COOKIE = '__Secure-refresh';
const REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 días
const COOKIE_OPTS = { httpOnly: true, secure: true, sameSite: 'strict', path: '/api/auth' };
// En producción: tabla en base de datos o Redis, nunca un Map en memoria.
const refreshStore = new Map(); // sha256(token) -> { user, expiresAt }
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
function issueAccessToken(user) {
return jwt.sign({ name: user.name, roles: user.roles }, ACCESS_SECRET, {
algorithm: 'HS256',
subject: String(user.id),
expiresIn: '10m',
});
}
function issueRefreshCookie(res, user) {
const raw = randomBytes(32).toString('base64url');
refreshStore.set(sha256(raw), { user, expiresAt: Date.now() + REFRESH_TTL_MS });
res.cookie(REFRESH_COOKIE, raw, { ...COOKIE_OPTS, maxAge: REFRESH_TTL_MS });
}
function requireAuth(req, res, next) {
const [scheme, token] = (req.headers.authorization ?? '').split(' ');
if (scheme !== 'Bearer' || !token) return res.sendStatus(401);
try {
// Fijar el algoritmo evita ataques de confusión (RFC 8725)
req.auth = jwt.verify(token, ACCESS_SECRET, { algorithms: ['HS256'] });
next();
} catch {
res.sendStatus(401);
}
}
const app = express();
app.use(express.json());
app.use(cookieParser());
app.post('/api/auth/login', async (req, res) => {
const user = await verifyCredentials(req.body.email, req.body.password); // tu lógica real (argon2, bcrypt...)
if (!user) return res.status(401).json({ message: 'Credenciales no válidas' });
issueRefreshCookie(res, user);
res.json({ accessToken: issueAccessToken(user), expiresIn: 600 });
});
app.post('/api/auth/refresh', (req, res) => {
const hash = sha256(req.cookies[REFRESH_COOKIE] ?? '');
const entry = refreshStore.get(hash);
refreshStore.delete(hash); // rotación: cada refresh token sirve una sola vez
if (!entry || entry.expiresAt < Date.now()) {
res.clearCookie(REFRESH_COOKIE, COOKIE_OPTS);
return res.sendStatus(401);
}
issueRefreshCookie(res, entry.user);
res.json({ accessToken: issueAccessToken(entry.user), expiresIn: 600 });
});
app.post('/api/auth/logout', (req, res) => {
refreshStore.delete(sha256(req.cookies[REFRESH_COOKIE] ?? ''));
res.clearCookie(REFRESH_COOKIE, COOKIE_OPTS);
res.sendStatus(204);
});
app.get('/api/profile', requireAuth, (req, res) => {
res.json({ id: req.auth.sub, name: req.auth.name, roles: req.auth.roles });
});
app.listen(3000);
Hay tres detalles que merecen explicación. Primero, el atributo Path=/api/auth hace que el navegador solo envíe la cookie a los tres endpoints de autenticación y no a cada petición de la API. Segundo, el prefijo __Secure- obliga al navegador a rechazar la cookie si no se establece con Secure desde una página HTTPS. Se podría usar __Host-, que exige además no llevar Domain y tener Path=/, pero esa ruta raíz la haría viajar en todas las peticiones. Tercero, fijar algorithms: ['HS256'] al verificar es una de las recomendaciones del RFC 8725 (buenas prácticas para JWT) para evitar que un atacante elija el algoritmo con el que se valida su token.
Para que la cookie con Secure funcione en desarrollo, accede por https:// o por localhost. Safari es la excepción: no acepta cookies Secure sobre http://localhost. El resultado en la respuesta del login es una cabecera de este estilo:
Set-Cookie: __Secure-refresh=Zk3...; Max-Age=604800; Path=/api/auth; Secure; HttpOnly; SameSite=Strict
AuthService: el estado de sesión con signals
El servicio concentra el token y todo lo derivado de él. El access token se guarda en un signal privado y se expone en solo lectura; el usuario y el booleano de sesión son computed, así que cualquier plantilla o guard que los lea se actualiza sola al iniciar o cerrar sesión.
// auth.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { Observable, catchError, finalize, map, of, shareReplay, tap, throwError } from 'rxjs';
export interface Credentials { email: string; password: string; }
interface TokenResponse { accessToken: string; expiresIn: number; }
export interface JwtPayload {
sub: string;
name: string;
roles: string[];
exp: number;
}
function decodePayload(token: string): JwtPayload | null {
try {
const [, payload = ''] = token.split('.');
const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes)) as JwtPayload;
} catch {
return null;
}
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly token = signal<string | null>(null);
readonly accessToken = this.token.asReadonly();
readonly user = computed(() => {
const token = this.token();
return token ? decodePayload(token) : null;
});
readonly isAuthenticated = computed(() => this.user() !== null);
// Petición de renovación en curso, compartida entre todos los que la pidan
private refreshInFlight$: Observable<string> | null = null;
login(credentials: Credentials): Observable<void> {
return this.http.post<TokenResponse>('/api/auth/login', credentials).pipe(
tap((res) => this.token.set(res.accessToken)),
map(() => undefined),
);
}
refresh(): Observable<string> {
this.refreshInFlight$ ??= this.http.post<TokenResponse>('/api/auth/refresh', {}).pipe(
map((res) => res.accessToken),
tap((token) => this.token.set(token)),
catchError((error) => {
this.token.set(null);
return throwError(() => error);
}),
finalize(() => (this.refreshInFlight$ = null)),
shareReplay(1),
);
return this.refreshInFlight$;
}
logout(): Observable<void> {
return this.http.post<void>('/api/auth/logout', {}).pipe(
catchError(() => of(undefined)),
tap(() => this.token.set(null)),
map(() => undefined),
);
}
}
Dos puntos de este servicio son fáciles de pasar por alto. El primero es que decodePayload solo lee el contenido del JWT para pintar el nombre o decidir qué botones mostrar; no verifica nada y no puede hacerlo, porque el cliente no tiene (ni debe tener) la clave de firma. Lo que el cliente «sabe» del usuario es información de interfaz, no una garantía de seguridad. El segundo es refreshInFlight$: si el usuario abre una pantalla que lanza cinco peticiones a la vez y el token ha caducado, las cinco recibirán un 401. Sin protección, cada una pediría su propio refresh y, con rotación en el servidor, cuatro de ellas fallarían porque el refresh token ya se habría gastado. Compartir una única petición en curso con shareReplay resuelve la carrera.
El interceptor: adjuntar el token y renovar ante un 401
El interceptor hace dos cosas. Añade la cabecera a las llamadas a la API que lo necesitan (y a ningún otro dominio, para no filtrar el token a terceros) y, si el servidor responde 401 teniendo un token, intenta renovarlo una vez y repite la petición original con el token nuevo.
// auth.interceptor.ts
import {
HttpErrorResponse,
HttpHandlerFn,
HttpInterceptorFn,
HttpRequest,
} from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from './auth.service';
const withBearer = (req: HttpRequest<unknown>, token: string) =>
req.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
const isProtectedApiCall = (url: string) =>
url.startsWith('/api/') && !url.startsWith('/api/auth/');
export const authInterceptor: HttpInterceptorFn = (req, next: HttpHandlerFn) => {
if (!isProtectedApiCall(req.url)) return next(req);
const auth = inject(AuthService);
const router = inject(Router);
const token = auth.accessToken();
const authorized = token ? withBearer(req, token) : req;
return next(authorized).pipe(
catchError((error: unknown) => {
const expired = error instanceof HttpErrorResponse && error.status === 401 && token;
if (!expired) return throwError(() => error);
return auth.refresh().pipe(
catchError((refreshError) => {
router.navigate(['/login'], { queryParams: { returnUrl: router.url } });
return throwError(() => refreshError);
}),
switchMap((newToken) => next(withBearer(req, newToken))),
);
}),
);
};
La petición original no se modifica, porque las peticiones de HttpClient son inmutables y por eso hay que usar clone(). Eso, además, facilita el reintento: req sigue intacta y se puede volver a enviar con otra cabecera. El reintento está fuera del catchError interno, de modo que si la segunda llamada también devuelve 401 el error llega al llamador y no se entra en un bucle. Las rutas /api/auth/ se excluyen a propósito: un 401 en el login significa credenciales incorrectas, no token caducado, y un 401 en el propio refresh dispararía una recursión.
Guards: proteger rutas y redirigir con returnUrl
Con el estado en signals, el guard es casi trivial. Si hay sesión deja pasar y, si no, devuelve un UrlTree hacia el login conservando la URL de destino. El segundo guard es una fábrica que recibe el rol exigido, lo que permite reutilizarlo en cualquier ruta.
// auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isAuthenticated()
? true
: router.createUrlTree(['/login'], { queryParams: { returnUrl: state.url } });
};
export const roleGuard = (role: string): CanActivateFn => () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.user()?.roles.includes(role) ? true : router.createUrlTree(['/forbidden']);
};
// app.routes.ts
import { Routes } from '@angular/router';
import { authGuard, roleGuard } from './auth.guard';
export const routes: Routes = [
{ path: 'login', loadComponent: () => import('./login-page').then((m) => m.LoginPage) },
{
path: 'cuenta',
canActivate: [authGuard],
loadComponent: () => import('./account-page').then((m) => m.AccountPage),
},
{
path: 'admin',
canActivate: [authGuard, roleGuard('admin')],
loadComponent: () => import('./admin-page').then((m) => m.AdminPage),
},
{ path: 'forbidden', loadComponent: () => import('./forbidden-page').then((m) => m.ForbiddenPage) },
{ path: '', pathMatch: 'full', redirectTo: 'cuenta' },
];
La documentación oficial insiste en que no hay que confiar en los guards del cliente como único control de acceso, y es literal: cualquiera puede abrir las herramientas de desarrollo, modificar el bundle o llamar a la API directamente. El guard mejora la experiencia (no enseña pantallas a quien no puede usarlas), pero la autorización real, la que comprueba roles y propietarios de los datos, se hace en el servidor en cada petición. Por eso el requireAuth del backend valida la firma en cada llamada y no se fía de lo que diga el cliente.
Arranque y configuración de la aplicación
Queda cablear todo en app.config.ts. La pieza importante es el inicializador: al recargar la página (o abrir una pestaña nueva) el signal está vacío, aunque la cookie del refresh token siga viva. Pidiendo un access token antes de arrancar, la primera navegación ya sabe si hay sesión y los guards no expulsan al usuario al login por un simple F5. Si la renovación falla se ignora el error, porque significa únicamente que no hay sesión.
// app.config.ts
import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { catchError, firstValueFrom, of } from 'rxjs';
import { routes } from './app.routes';
import { authInterceptor } from './auth.interceptor';
import { AuthService } from './auth.service';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
// Al recargar la página el access token en memoria desaparece:
// se recupera pidiendo uno nuevo con la cookie del refresh token.
provideAppInitializer(() => {
const auth = inject(AuthService);
return firstValueFrom(auth.refresh().pipe(catchError(() => of(null))));
}),
],
};
Y un formulario de login mínimo, con formularios reactivos y signals para el estado de carga y el error. Merece la pena la comprobación de returnUrl: solo se acepta si empieza por /, para que nadie pueda construir un enlace de login que redirija a un sitio externo tras autenticarse (una costumbre sana con cualquier parámetro que llegue por la URL).
// login-page.ts
import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { AuthService } from './auth.service';
@Component({
selector: 'app-login-page',
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<input type="email" formControlName="email" autocomplete="username" />
<input type="password" formControlName="password" autocomplete="current-password" />
@if (error()) {
<p role="alert">{{ error() }}</p>
}
<button type="submit" [disabled]="form.invalid || loading()">Entrar</button>
</form>
`,
})
export class LoginPage {
private readonly auth = inject(AuthService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly form = inject(FormBuilder).nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required],
});
submit(): void {
this.loading.set(true);
this.error.set(null);
this.auth.login(this.form.getRawValue()).subscribe({
next: () => {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/cuenta';
this.router.navigateByUrl(returnUrl.startsWith('/') ? returnUrl : '/cuenta');
},
error: () => {
this.error.set('Correo o contraseña incorrectos');
this.loading.set(false);
},
});
}
}
Dónde guardar el token: la comparación honesta
Esta es la parte donde más tutoriales fallan, y donde no hay una respuesta gratuita. Las opciones reales son estas:
Opción | Sobrevive a F5 | Lo roba un XSS | CSRF | Complejidad |
| Sí, y entre pestañas | Sí, y se puede reutilizar desde fuera hasta que caduque | No aplica (se envía a mano) | Mínima |
| Solo en esa pestaña | Sí | No aplica | Mínima |
Memoria + refresh en cookie HttpOnly | Sí, vía refresh al arrancar | El refresh token no; el access token en memoria sí, pero dura minutos | Hay que cubrir el endpoint de refresh | Media |
Todo en cookie HttpOnly (sesión o patrón BFF) | Sí | No se puede leer, aunque el script puede lanzar peticiones | Hay que cubrir todas las peticiones que modifican datos | Alta (servidor intermedio) |
La guía de seguridad HTML5 de OWASP es tajante con el almacenamiento del navegador: no guardar identificadores de sesión en localStorage, porque JavaScript siempre puede leerlos, y recomienda cookies con la marca HttpOnly. Añade que un único fallo de XSS basta para vaciar todos esos almacenes. Angular ayuda mucho a evitar el XSS (escapa la interpolación y sanea los valores en contextos de HTML y URL), pero no lo elimina: el riesgo vuelve con [innerHTML], con los métodos bypassSecurityTrust*, con acceso directo al DOM y, sobre todo, con las dependencias de terceros que cargas en tu bundle o por script.
El esquema de memoria más cookie reduce el daño, no lo anula, y conviene decirlo claro. Si un atacante ejecuta código en tu página, no puede llevarse el refresh token (la cookie es HttpOnly), pero sí puede hacer lo que hace tu aplicación: llamar a /api/auth/refresh desde el propio origen, obtener un access token y usarlo mientras la pestaña siga abierta. Lo que se evita es la exfiltración persistente: con localStorage el atacante se lleva un token y lo usa desde su máquina hasta que caduque; con este diseño solo actúa mientras su script viva en tu página. Para contener también eso hacen falta defensas en capas, empezando por una Content Security Policy estricta. Angular, además, documenta la integración con Trusted Types mediante la cabecera Content-Security-Policy: trusted-types angular; require-trusted-types-for 'script'; (y la política angular#unsafe-bypass si usas los métodos bypassSecurityTrust*).
CSRF: el riesgo se traslada al endpoint de refresh
Un token en la cabecera Authorization no sufre CSRF: el navegador no lo adjunta por su cuenta a peticiones iniciadas desde otra web. En cambio, una cookie sí viaja sola, y eso expone el endpoint que depende de ella. Tu exposición se reduce a /api/auth/refresh (y logout), y se cubre con varias capas pequeñas:
SameSite=Strict: la cookie solo se envía en peticiones iniciadas desde el mismo sitio. En una SPA no estorba, porque el documento HTML estático no necesita la cookie y la llamada de refresh la lanza tu propio JavaScript, que sí es del mismo sitio. Si no indicasSameSite, los navegadores actuales tratan la cookie comoLax.- Solo
POSTpara refresh y logout, nuncaGET. - Comprobar la cabecera
Originen esos endpoints y rechazar lo que no coincida con tu dominio. - Doble envío de token, si quieres una capa más.
HttpClientimplementa solo la mitad cliente: lee una cookie (por defectoXSRF-TOKEN) y la copia en la cabeceraX-XSRF-TOKENen las peticiones que modifican datos; el servidor tiene que emitir esa cookie y validar la cabecera, o la protección no sirve de nada. Puedes cambiar los nombres conwithXsrfConfiguration, y desactivarla conwithNoXsrfProtection().
provideHttpClient(
withInterceptors([authInterceptor]),
withXsrfConfiguration({ cookieName: 'XSRF-TOKEN', headerName: 'X-XSRF-TOKEN' }),
)
Si tu API vive en otro origen (por ejemplo api.ejemplo.com y la web en app.ejemplo.com) todo se complica un poco: las llamadas necesitan withCredentials: true, el servidor debe responder con Access-Control-Allow-Credentials: true y un origen explícito (no vale el comodín), y la cookie tiene que declararse con un Domain compartido o SameSite=None; Secure, lo que debilita la protección. Siempre que puedas, sirve la API bajo el mismo origen que la aplicación.
Errores frecuentes y qué comprobar antes de salir a producción
- Enviar el token a cualquier URL. El interceptor del ejemplo filtra por prefijo. Un interceptor que añade la cabecera a todas las peticiones regala tu JWT a cualquier API de terceros que llames.
- Tokens de acceso de larga duración. Un JWT de siete días no se puede revocar. Mantenlo en minutos y delega la continuidad en el refresh token.
- Refresh token reutilizable. Sin rotación, una copia robada es válida hasta que expire. Con rotación, además, la reutilización de un token ya gastado es una señal de robo: lo normal en producción es agrupar los refresh tokens en «familias» y revocar la familia entera cuando se detecta ese uso.
- Información sensible en el payload. El JWT va firmado, no cifrado: cualquiera puede leer su contenido en base64. Nada de datos personales ni secretos.
- Confiar en el cliente. Los roles del token sirven para la interfaz; el servidor los vuelve a comprobar en cada petición.
- Olvidar el logout en el servidor. Borrar el signal cierra la sesión en la pantalla, pero el refresh token sigue siendo válido hasta que lo invalides en el servidor y borres la cookie.
- Clave de firma débil o compartida. Con HS256 la clave debe ser aleatoria y larga; si varios servicios necesitan verificar los tokens sin poder emitirlos, usa firma asimétrica (RS256 o ES256) y publica solo la clave pública.
Con este esqueleto tienes un flujo de autenticación que respeta las convenciones actuales de Angular y asume que el XSS puede ocurrir, en lugar de dar por hecho que no. Los tres bloques (servicio con signals, interceptor con renovación compartida y guards funcionales) son piezas pequeñas que se pueden probar por separado, y el modelo de memoria más cookie se puede sustituir por un BFF si el equipo prefiere mantener todos los tokens fuera del navegador.
Fuentes consultadas: documentación oficial de Angular sobre interceptores, guards de ruta y seguridad; guía de cookies de MDN; OWASP HTML5 Security Cheat Sheet y RFC 8725.
Imagen: Pexels / Rafael Minguet Delgado
