Saltar a contenido

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.

npm install jwt-decode
src/app/core/services/auth.service.ts
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.

src/app/core/models.ts
/** 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;
}
src/app/core/api.ts
export const API_URL = 'http://localhost:8080';

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?

src/app/core/guards/auth.guard.ts
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?

src/app/core/guards/rol.guard.ts
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

src/app/app.routes.ts
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

src/app/pages/login/login.ts
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"]
src/app/core/interceptors/auth.interceptor.ts
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);
};
src/app/app.config.ts
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

JwtService.java
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();
}
AuthService.java
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());
}
AuthController.java
@PostMapping("/refresh")
public LoginResponse refrescar(@Valid @RequestBody RefreshRequest r) {
    return authService.refrescar(r.refreshToken());
}
SecurityConfig.java
.requestMatchers(HttpMethod.POST, "/auth/login", "/auth/registro", "/auth/refresh").permitAll()

Dos detalles que suelen olvidarse en el backend

  • /auth/refresh es público: cuando se llama, el access token ya venció.
  • El filtro JWT debe rechazar un token con tipo: refresh enviado en Authorization; como no trae roles, si no se valida termina en un NullPointerException.
  • Hay que habilitar CORS para http://localhost:4200 con los headers Authorization y Content-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.

src/app/core/services/auth.service.ts (se agrega al AuthService)
/** 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

src/app/core/interceptors/refresh.interceptor.ts
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: generarRefreshToken con el claim tipo: refresh y POST /auth/refresh público.
  • Backend: el filtro JWT rechaza un refresh token en Authorization; CORS habilitado para localhost:4200.
  • npm install jwt-decode.
  • AuthService con login, logout, getToken, getRefreshToken, estaAutenticado, tieneRol y refrescar.
  • authGuard y rolGuard en core/guards/, aplicados en app.routes.ts en ese orden.
  • Página /login que respete returnUrl, y página /sin-permiso.
  • authInterceptor con necesitaToken y refreshInterceptor registrados en app.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
# Frontend
ng serve

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. localStorage es simple, pero cualquier script lo lee (XSS). Más seguro: refresh token en cookie HttpOnly; 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