10. JWT en el frontend: guards, interceptors y refresh token¶
Guardar el token, enviarlo en cada petición y renovarlo cuando vence.
Pregunta guía
Si el servidor no guarda sesiones, ¿quién recuerda que iniciaste sesión?
Por qué el frontend se vuelve responsable del token¶
| Sesión con estado | JWT sin estado (stateless) | |
|---|---|---|
| Quién recuerda | El servidor: guarda la sesión en memoria | El cliente: cada petición trae el token |
| Qué viaja | Una cookie (JSESSIONID) |
Authorization: Bearer eyJhbGciOi… |
| Qué contiene | Solo un identificador | Quién es (sub), sus roles y cuándo vence (exp) |
Consecuencia: el frontend debe guardar el token, enviarlo en cada petición y renovarlo cuando vence.
El token se lee, pero no se verifica¶
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbmEiLCJyb2xlcyI6WyJST0xF….SflKxwRJSMeKKF2QT4fwpM
header · payload (sub, roles, exp) · firma HS256
| Lo que sí puede el navegador | Lo que no puede |
|---|---|
| Leer el payload: es Base64, no cifrado | Verificar la firma: la clave secreta solo existe en el servidor |
Editarlo: cambiar ROLE_USER por ROLE_ADMIN |
Lograr que Spring acepte ese token alterado |
La regla que ordena todo el capítulo
El frontend lee el token para decidir qué mostrar; Spring lo verifica para decidir qué permitir.
Tres problemas, tres piezas¶
| Problema | La pregunta | Pieza |
|---|---|---|
| Enviar el token siempre | ¿Cómo no repetirlo en cada servicio? | Interceptor |
| Proteger rutas del cliente | ¿Quién decide si entra a /admin? |
Guard |
| El token vence | ¿Cómo seguir sin pedir la contraseña? | Refresh token |
| El cliente no es confiable | ¿Quién tiene la última palabra? | Spring: firma, 401 y 403 |
Las tres piezas del frontend comparten un AuthService: el único que sabe dónde está el token.
flowchart LR
L["01 · Login<br/>POST /auth/login devuelve<br/>token y refreshToken"] --> A["02 · AuthService<br/>guarda los tokens y responde:<br/>¿hay sesión?, ¿qué rol?"]
A --> G["03 · Guard<br/>antes de entrar a una ruta<br/>revisa sesión y rol"]
G --> I["04 · Interceptor<br/>agrega Authorization: Bearer<br/>a cada petición"]
I --> R["05 · Refresh<br/>si la API responde 401,<br/>pide tokens nuevos"]
R -. "reintenta con el token nuevo" .-> I
0. La base: AuthService¶
Todo lo demás le pregunta a este servicio: la página de login, los dos guards, los dos interceptors y el botón Salir.
import { HttpClient } from '@angular/common/http';
import { Injectable, inject, signal } from '@angular/core';
import { jwtDecode } from 'jwt-decode';
import { Observable, finalize, shareReplay, tap } from 'rxjs';
import { API_URL } from '../api';
import { LoginResponse, TokenPayload } from '../models';
const CLAVE_TOKEN = 'token';
const CLAVE_REFRESH = 'refreshToken';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly api = `${API_URL}/auth`;
/** Usuario de la sesión (el "sub" del token), para mostrarlo en la barra de navegación. */
readonly usuario = signal<string | null>(this.leerPayload()?.sub ?? null);
login(username: string, password: string): Observable<LoginResponse> {
return this.http
.post<LoginResponse>(`${this.api}/login`, { username, password })
.pipe(tap((respuesta) => this.guardar(respuesta)));
}
logout(): void {
localStorage.removeItem(CLAVE_TOKEN);
localStorage.removeItem(CLAVE_REFRESH);
this.usuario.set(null);
}
getToken(): string | null {
return localStorage.getItem(CLAVE_TOKEN);
}
getRefreshToken(): string | null {
return localStorage.getItem(CLAVE_REFRESH);
}
/** Hay access token y no ha vencido (exp viene en segundos, Date.now() en milisegundos). */
estaAutenticado(): boolean {
const payload = this.leerPayload();
return !!payload && payload.exp * 1000 > Date.now();
}
/** El rol llega como ROLE_ADMIN: Spring agrega el prefijo ROLE_ al usar .roles("ADMIN"). */
tieneRol(rol: string): boolean {
return this.leerPayload()?.roles?.includes(rol) ?? false;
}
/** Decodificar no es verificar: la firma la valida el backend. */
leerPayload(): TokenPayload | null {
const token = this.getToken();
if (!token) {
return null;
}
try {
return jwtDecode<TokenPayload>(token);
} catch {
return null;
}
}
private guardar(respuesta: LoginResponse): void {
localStorage.setItem(CLAVE_TOKEN, respuesta.token);
localStorage.setItem(CLAVE_REFRESH, respuesta.refreshToken);
this.usuario.set(this.leerPayload()?.sub ?? null);
}
}
El método refrescar() se agrega más adelante, en la sección 3.
/** Lo que devuelven /auth/login y /auth/refresh (LoginResponse en el backend). */
export interface LoginResponse {
token: string;
refreshToken: string;
tipo: string;
expiraEnSegundos: number;
}
/** Payload del access token que arma JwtService.generarToken. */
export interface TokenPayload {
sub: string;
roles: string[];
iss: string;
iat: number;
exp: number;
}
1. Guards: ¿quién entra a cada ruta?¶
En una SPA las rutas viven en el cliente: si el usuario escribe /admin en la barra, el Router cambia la vista sin preguntarle al servidor y la página se pinta vacía mientras la API responde 403. Un guard se ejecuta antes de activar la ruta.
| Situación | Decisión del guard |
|---|---|
| Sin token | Redirige a /login y guarda la URL pedida en returnUrl |
exp vencido |
Intenta un refresh; si falla, /login |
Sin ROLE_ADMIN |
Redirige a /sin-permiso: el token sirve, falta el rol |
flowchart LR
U["1 · Usuario<br/>navega a /admin"] --> AG{"2 · authGuard<br/>¿sesión vigente?"}
AG -- No --> L["Intenta refresh;<br/>si falla: /login?returnUrl"]
AG -- Sí --> RG{"3 · rolGuard<br/>¿trae ROLE_ADMIN?"}
RG -- No --> SP["/sin-permiso"]
RG -- Sí --> S["4 · Spring<br/>valida el rol al pedir los datos"]
S --> OK["200 · se muestra la página Admin"]
S --> F["403 · rechaza aunque<br/>alguien engañe al guard"]
authGuard: ¿hay sesión?¶
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { catchError, map, of } from 'rxjs';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
if (auth.estaAutenticado()) {
return true;
}
// Manda al login y recuerda a dónde quería ir
const login = inject(Router).createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
if (!auth.getRefreshToken()) {
return login;
}
// Token vencido pero con refresh: se intenta renovar antes de rendirse
return auth.refrescar().pipe(
map(() => true),
catchError(() => {
auth.logout();
return of(login);
}),
);
};
true deja pasar; devolver un UrlTree redirige. El guard también puede devolver un Observable, y por eso puede esperar al refresh.
rolGuard: ¿tiene el rol?¶
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const rolGuard: CanActivateFn = (route) => {
const auth = inject(AuthService);
const rol = route.data['rol'] as string;
if (auth.tieneRol(rol)) {
return true;
}
return inject(Router).createUrlTree(['/sin-permiso']);
};
Conectarlos a las rutas¶
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'productos' },
{ path: 'login', component: Login },
// Público
{ path: 'productos', component: Productos },
// Con token
{ path: 'perfil', component: Perfil, canActivate: [authGuard] },
// Con token y rol ADMIN: primero la sesión, luego el rol
{
path: 'admin',
component: Admin,
canActivate: [authGuard, rolGuard],
data: { rol: 'ROLE_ADMIN' },
},
{ path: 'sin-permiso', component: SinPermiso },
{ path: '**', redirectTo: 'productos' },
];
Los guards corren en el orden de canActivate: si authGuard redirige, rolGuard ni siquiera se ejecuta. Las rutas reflejan las reglas de SecurityConfig en el backend.
ROLE_ADMIN, no ADMIN
Spring agrega el prefijo ROLE_ cuando se declara .roles("ADMIN"), y así viaja en el claim roles. El data.rol de la ruta debe escribirse igual.
Volver a donde iba: returnUrl¶
this.auth.login(this.username, this.password).subscribe({
next: () => {
// Vuelve a la página que pidió antes de que el guard lo mandara al login
const url = this.route.snapshot.queryParamMap.get('returnUrl') ?? '/perfil';
this.router.navigateByUrl(url);
},
error: (e: HttpErrorResponse) => {
this.error.set(
e.status === 401 ? 'Usuario o contraseña incorrectos' : 'No se pudo conectar con la API',
);
this.cargando.set(false);
},
});
El guard mejora la experiencia, no protege datos
Cualquiera puede editar el localStorage y engañar al guard: vería la página vacía, porque la API sigue respondiendo 401 y 403. La protección real vive en Spring.
2. Interceptors: el token en cada petición¶
Sin interceptor, cada servicio arma su propio header… y tarde o temprano uno lo olvida:
// producto.service.ts
headers: { Authorization: `Bearer ${t}` }
// perfil.service.ts
headers: { Authorization: `Bearer ${t}` }
// pedido.service.ts
// …¿alguien lo olvidó? → un 401 difícil de rastrear
Un interceptor es un solo punto por el que pasan todas las peticiones de HttpClient.
flowchart LR
C["1 · Componente<br/>http.get('/api/perfil')"] --> I{"2 · authInterceptor<br/>¿necesita token?"}
I -- Sí --> CL["Clona con<br/>Authorization: Bearer"]
I -- "No · /auth/, otro dominio<br/>o sin token" --> SN["Sigue sin header"]
CL --> S["3 · Spring<br/>valida firma, emisor y exp"]
SN --> S
S --> R200["200 · recibe los datos"]
S --> R401["401 · pasa al flujo de refresh"]
S --> R403["403 · falta el rol"]
import { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { API_URL } from '../api';
import { AuthService } from '../services/auth.service';
/** El access token solo viaja a nuestra API, y nunca a /auth/ (login, registro y refresh). */
export function necesitaToken(url: string): boolean {
return url.startsWith(API_URL) && !url.startsWith(`${API_URL}/auth/`);
}
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).getToken();
if (!token || !necesitaToken(req.url)) {
return next(req);
}
// La petición es inmutable: se clona con el header
const authReq = req.clone({
setHeaders: { Authorization: `Bearer ${token}` },
});
return next(authReq);
};
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
// El orden importa: authInterceptor pone el token y refreshInterceptor atrapa el 401
provideHttpClient(withInterceptors([authInterceptor, refreshInterceptor])),
],
};
Tres decisiones que vale la pena entender
- Solo a
API_URL: si la app llama a otro dominio, el token no se filtra a terceros. - Nunca a
/auth/: ahí todavía no hay token; es donde se obtiene. req.clone(): la petición es inmutable, no se le pueden agregar headers in situ.
3. Refresh token: seguir sin pedir la contraseña¶
El dilema de la duración¶
exp = 7 días · token largo |
exp = 15 minutos · token corto |
|---|---|
| ✅ Casi nunca hay que iniciar sesión otra vez | ✅ Si lo roban, sirve pocos minutos |
| ❌ Si lo roban, sirve días | ❌ Cada 15 minutos hay que escribir la contraseña |
| ❌ Sin estado, el servidor no puede revocarlo | ❌ El usuario pierde lo que estaba haciendo |
Ningún valor único es seguro y cómodo a la vez. La salida es separar el permiso de entrar del permiso de renovar:
| Access token | Refresh token | |
|---|---|---|
| Duración | 15 minutos | 7 días |
| Se envía a | Toda la API, en Authorization |
Solo POST /auth/refresh, en el cuerpo |
| Contiene | sub, roles, exp |
sub, tipo: refresh, exp |
| Si se filtra | Sirve pocos minutos | Hay que poder revocarlo |
En el backend¶
public String generarRefreshToken(UserDetails usuario) {
Instant ahora = Instant.now();
return Jwts.builder()
.subject(usuario.getUsername())
.claim("tipo", "refresh") // lo distingue del access token
.issuer(EMISOR)
.issuedAt(Date.from(ahora))
.expiration(Date.from(ahora.plus(Duration.ofDays(7))))
.signWith(clave, Jwts.SIG.HS256) // misma clave y emisor; otra duración
.compact();
}
public LoginResponse refrescar(String refreshToken) {
Claims claims = jwtService.validarToken(refreshToken);
if (!"refresh".equals(claims.get("tipo"))) {
throw new TokenInvalidoException("El token enviado no es un refresh token");
}
// Recarga el usuario: así un cambio de rol se refleja en el siguiente access token
UserDetails usuario = usuarioDetailsService.loadUserByUsername(claims.getSubject());
return new LoginResponse(jwtService.generarToken(usuario),
jwtService.generarRefreshToken(usuario),
"Bearer", jwtService.getExpiracionEnSegundos());
}
@PostMapping("/refresh")
public LoginResponse refrescar(@Valid @RequestBody RefreshRequest r) {
return authService.refrescar(r.refreshToken());
}
.requestMatchers(HttpMethod.POST, "/auth/login", "/auth/registro", "/auth/refresh").permitAll()
Dos detalles que suelen olvidarse en el backend
/auth/refreshes público: cuando se llama, el access token ya venció.- El filtro JWT debe rechazar un token con
tipo: refreshenviado enAuthorization; como no traeroles, si no se valida termina en unNullPointerException. - Hay que habilitar CORS para
http://localhost:4200con los headersAuthorizationyContent-Type, o el navegador bloquea las llamadas.
En el frontend: pedir tokens nuevos una sola vez¶
Si tres peticiones reciben 401 al mismo tiempo, no deben disparar tres refresh: todas esperan el mismo.
/** Refresh en curso: varias peticiones con 401 lo comparten en lugar de pedir uno cada una. */
private refresco$?: Observable<LoginResponse>;
refrescar(): Observable<LoginResponse> {
this.refresco$ ??= this.http
.post<LoginResponse>(`${this.api}/refresh`, { refreshToken: this.getRefreshToken() })
.pipe(
tap((respuesta) => this.guardar(respuesta)),
finalize(() => (this.refresco$ = undefined)), // libera el observable al terminar
shareReplay(1), // reparte la misma respuesta
);
return this.refresco$;
}
Atrapar el 401 y reintentar¶
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { necesitaToken } from './auth.interceptor';
export const refreshInterceptor: HttpInterceptorFn = (req, next) => {
// Se inyectan aquí: dentro de catchError ya no hay contexto de inyección
const auth = inject(AuthService);
const router = inject(Router);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
// Solo 401 de nuestra API, y nunca sobre /auth/ (evita bucles con el propio refresh)
if (error.status !== 401 || !necesitaToken(req.url) || !auth.getRefreshToken()) {
return throwError(() => error);
}
return auth.refrescar().pipe(
catchError((errorRefresh) => {
// El refresh también falló: fin de la sesión
auth.logout();
router.navigate(['/login'], { queryParams: { returnUrl: router.url } });
return throwError(() => errorRefresh);
}),
// Repite la petición original con el token nuevo
switchMap((respuesta) =>
next(req.clone({ setHeaders: { Authorization: `Bearer ${respuesta.token}` } })),
),
);
}),
);
};
sequenceDiagram
participant C as Componente
participant I as refreshInterceptor
participant A as AuthService
participant S as Spring
C->>S: GET /api/perfil · Bearer (vencido)
S-->>I: 401 Unauthorized
I->>A: refrescar()
A->>S: POST /auth/refresh { refreshToken }
S-->>A: 200 · tokens nuevos
A-->>I: guarda los tokens
I->>S: GET /api/perfil · Bearer (nuevo)
S-->>C: 200 OK · el usuario no ve nada
Note over I,S: Si /auth/refresh responde 401:<br/>logout() y /login
¿En qué momento se refresca?¶
| ✅ Sí refrescar | ❌ No refrescar |
|---|---|
| Al recibir un 401 de la API: el servidor dice que venció | En cada petición: expone el refresh token sin necesidad |
Al navegar con exp vencido (authGuard), antes de mostrar la página |
Ante un 403: renovar el token no cambia el rol |
| (Opcional) con un temporizador antes de vencer; depende del reloj del cliente | Ante un 401 de /auth/: la sesión terminó |
Regla: refrescar solo si el access token ya no sirve. Si el refresh también falla →
logout()y/login.
Checklist de implementación¶
- Backend:
generarRefreshTokencon el claimtipo: refreshyPOST /auth/refreshpúblico. - Backend: el filtro JWT rechaza un refresh token en
Authorization; CORS habilitado paralocalhost:4200. -
npm install jwt-decode. -
AuthServiceconlogin,logout,getToken,getRefreshToken,estaAutenticado,tieneRolyrefrescar. -
authGuardyrolGuardencore/guards/, aplicados enapp.routes.tsen ese orden. - Página
/loginque respetereturnUrl, y página/sin-permiso. -
authInterceptorconnecesitaTokenyrefreshInterceptorregistrados enapp.config.ts, en ese orden. - Ningún servicio arma headers a mano: solo llaman
http.get().
Cómo probarlo sin esperar 15 minutos¶
# Backend: el access token vence en un minuto
./mvnw spring-boot:run -Dspring-boot.run.arguments=--jwt.expiration-minutes=1
Con la pestaña Red del navegador abierta se ve la secuencia 401 → POST /auth/refresh → reintento. Usuarios de prueba del ejemplo: ana / ana123 (USER) y admin / admin123 (ADMIN).
Otras comprobaciones útiles en clase:
| Prueba | Resultado esperado |
|---|---|
Entrar a /perfil sin iniciar sesión |
Redirige a /login?returnUrl=/perfil; tras el login vuelve a /perfil |
Entrar a /admin como ana |
Redirige a /sin-permiso |
Editar el payload en localStorage para ponerse ROLE_ADMIN |
El guard deja pasar, pero la API responde 401 (firma inválida) |
Borrar el refreshToken y esperar a que venza el access token |
El 401 no se reintenta: la sesión termina en /login |
Cuatro decisiones que evitan problemas¶
Buenas prácticas
- Dónde guardar los tokens.
localStoragees simple, pero cualquier script lo lee (XSS). Más seguro: refresh token en cookieHttpOnly; Secure; SameSite. - Un refresh a la vez. Varias peticiones con 401 comparten el mismo observable con
shareReplay. - Sin bucles. Los interceptors ignoran
/auth/**; si el refresh falla,logout()y/login. - La seguridad vive en Spring. Guards e interceptors son comodidad: firma, expiración y roles se validan en el backend.
Limitaciones del ejemplo
El refresh token del ejemplo no se puede revocar: sirve hasta que vence (7 días) y cerrar sesión en el frontend no lo invalida en el servidor. En producción se guarda en base de datos (o su jti) para poder revocarlo, y se rota: cada refresh invalida el anterior.
Referencias¶
- El lado del servidor: Seguridad con JWT en Spring Boot
- Backend del ejemplo: github.com/pablomarquezfernandez/jwt-demo · detalle en su
IMPLEMENTACION.md - Frontend del ejemplo: github.com/pablomarquezfernandez/jwt-demo-front
- Inspeccionar un token: jwt.io
- Angular: guards · interceptors
- Relacionado: Routing, Servicios y Excepciones