CicloUrbana ya sabe quién es cada ciudadano de Ribalta, pero lo pregunta de una forma que no aguanta el uso real. Con HTTP Basic, la aplicación móvil tiene que guardar la contraseña de Marta y enviarla en cada petición: al abrir el mapa, al consultar una estación, al iniciar un alquiler. La credencial más valiosa del usuario recorre la red decenas de veces al día, el servidor calcula un BCrypt de noventa milisegundos en cada una, y no hay manera de caducar un acceso sin obligar a cambiar la contraseña.

Esta lección lo resuelve con el patrón estándar de las APIs modernas: la contraseña se envía una sola vez, y a cambio el servidor emite un token firmado y caducable que el cliente presenta después en la cabecera Authorization: Bearer. Veremos qué es exactamente un JWT campo a campo, lo implementaremos con JJWT y un filtro propio, añadiremos tokens de refresco con rotación y revocación en base de datos, y afrontaremos con honestidad las dos preguntas incómodas del modelo sin estado: dónde guarda el cliente el token y qué significa realmente cerrar sesión.

Advertencia. Todos los secretos, claves y tokens de esta lección son ficticios y están truncados; ninguno debe reutilizarse. El secreto de firma jamás se escribe en el repositorio: se inyecta por variable de entorno o gestor de secretos. Y una implementación propia de JWT, aunque use una librería sólida, debe ser revisada por un profesional de seguridad antes de exponerse a Internet; el apartado 15 explica la alternativa que evita escribir este código.

Contenido

  1. Por qué una app móvil necesita autenticación sin estado
  2. Sesión con cookie frente a token: la comparación honesta
  3. Anatomía de un JWT
  4. Los claims: estándar y propios de CicloUrbana
  5. Algoritmos de firma: HS256 frente a RS256
  6. Firmado no es cifrado
  7. JJWT y JwtProperties
  8. ServicioJwt: generar, leer y validar
  9. FiltroAutenticacionJwt
  10. Registrar el filtro en la cadena de seguridad
  11. POST /api/v1/auth/login
  12. Tokens de refresco: rotación y revocación
  13. Cerrar sesión en un mundo sin estado
  14. Dónde guarda el token el cliente
  15. Documentar el esquema en OpenAPI y la alternativa para producción
  16. Probar el flujo completo con curl
  17. Errores Comunes y Consejos
  18. Ejercicios

  1. Por qué una app móvil necesita autenticación sin estado

La alternativa clásica al token es la sesión en el servidor: el usuario entra una vez, el servidor guarda su estado en memoria y le entrega una cookie con un identificador. Funciona muy bien para una web tradicional y muy mal para CicloUrbana:

  • El cliente no es un navegador. Una aplicación móvil no gestiona cookies de forma natural, y la política de terceros de los navegadores complica cada vez más el escenario web.
  • La sesión vive en una instancia. Con tres réplicas detrás de un balanceador, la sesión de Marta está en la número 2 y las otras dos no la conocen: las salidas son sesión pegajosa (frágil) o replicación (cara). Con un token, cualquier instancia atiende cualquier petición.
  • Contradice la restricción REST de 03-01, que exige que cada petición contenga toda la información necesaria, y consume memoria proporcional al número de usuarios conectados.

La solución sin estado invierte el almacenamiento: el estado viaja con el cliente, firmado por el servidor para que no pueda manipularlo. El servidor no recuerda nada; solo verifica una firma.

  1. Sesión con cookie frente a token: la comparación honesta

Sesión con cookie Token JWT
Dónde vive el estado En el servidor En el cliente
Escala horizontal Requiere sesión pegajosa o almacén compartido Trivial
Revocación inmediata Sí: se borra la sesión No: vale hasta que caduca
Tamaño por petición ~50 bytes 300–1000 bytes
Cambio de roles / cliente móvil Inmediato / incómodo Hasta la caducidad / natural
CSRF Vulnerable, exige token anti-CSRF No aplica si va en cabecera
XSS La cookie HttpOnly no es legible por JS Grave si se guarda en localStorage

Los tres inconvenientes del JWT hay que decirlos sin adornos, porque casi nunca se mencionan:

No se puede revocar fácilmente. Un token robado es válido hasta que expira y no hay nada en el servidor que lo invalide: si Marta pierde el móvil, su token sigue funcionando. La defensa principal es la caducidad corta, y por eso el apartado 12 introduce los tokens de refresco. Ocupa espacio: un JWT con roles ronda los 400 bytes que viajan en cada petición, un coste real sobre una conexión móvil. Y es peligroso si se almacena mal: guardarlo en localStorage lo hace legible por cualquier JavaScript de la página, de modo que un XSS pasa de ser un problema molesto a un robo de identidad completo (apartado 14).

Un JWT no es la mejor opción siempre. Para una aplicación web clásica con vistas en el servidor, la sesión con cookie sigue siendo más simple y más segura. CicloUrbana elige el token porque su cliente es móvil y su API es sin estado, no porque sea moderno.

  1. Anatomía de un JWT

Un JSON Web Token (RFC 7519) son tres bloques codificados en Base64URL y separados por puntos:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYXJ0YUByaWJhbHRhLmVzIiwia
WRVc3VhcmlvIjo0MiwiaXNzIjoiY2ljbG91cmJhbmEifQ.K3Bn1sV7oQ2xJ0dLpR8mFq4tYc9Z
└──────── cabecera ────────┘ └──────────── carga útil ────────────┘ └ firma ┘

Al decodificar los dos primeros bloques —que es trivial: echo '<bloque>' | base64 -d— se obtiene JSON:

{ "alg": "HS256", "typ": "JWT" }
{ "iss": "ciclourbana",
  "sub": "[email protected]",
  "idUsuario": 42,
  "roles": ["ROLE_CIUDADANO"],
  "iat": 1773561600,
  "exp": 1773562500,
  "jti": "8f2a4c31-5b7e-4d19-9c02-6ea3f1b0d47c" }

Y el tercer bloque, la firma, se calcula así:

firma = HMAC-SHA256( base64url(cabecera) + "." + base64url(carga), secreto )

Base64URL no es cifrado: es una codificación reversible que existe solo para que el token viaje sin caracteres problemáticos en una URL o una cabecera. Cualquiera puede leer la carga útil; lo que nadie puede hacer sin el secreto es producir una firma válida. La verificación funciona así:

flowchart LR
    S["Separar<br/>cabecera . carga . firma"] --> R["Recalcular el HMAC<br/>con el secreto del servidor"]
    R --> C{"¿Coincide<br/>la firma?"}
    C -- "No" --> X["Manipulado o falso → 401"]
    C -- "Sí" --> E{"¿exp en<br/>el futuro?"}
    E -- "No" --> Y["Caducado → 401"]
    E -- "Sí" --> OK["Autenticar"]

Si un atacante cambia "roles":["ROLE_CIUDADANO"] por "roles":["ROLE_ADMIN"], la carga útil cambia, el HMAC recalculado ya no coincide con la firma que acompaña al token y el servidor lo rechaza. No puede recalcular la firma correcta porque no tiene el secreto.

  1. Los claims: estándar y propios de CicloUrbana

Cada campo de la carga útil es un claim, una afirmación sobre el sujeto. El RFC define siete registrados:

Claim Nombre Para qué sirve En CicloUrbana
iss Issuer Quién emitió el token ciclourbana
sub Subject De quién habla El correo del usuario
aud Audience Para quién es válido ciclourbana-api
exp Expiration Instante tras el cual no vale Emisión + 15 minutos
nbf / iat Not before / Issued at Desde cuándo vale, cuándo se emitió Solo iat
jti JWT ID Identificador único del token UUID, para revocación

Los tres primeros importan más de lo que parece. Validar iss y aud evita que un token emitido por otro sistema —o para otro servicio de la misma organización— sea aceptado aquí, un fallo real y frecuente en arquitecturas con varios servicios; y jti es lo que hace posible una lista de revocación (apartado 13). A ellos CicloUrbana añade dos claims propios:

Claim propio Tipo Por qué
roles Lista de cadenas Evita consultar la base de datos en cada petición para saber qué puede hacer el usuario
idUsuario Número El mismo motivo por el que UsuarioAutenticado guarda el id (05-03): permite comprobar la propiedad de un alquiler sin consultar

Cada claim que añades es un compromiso. Agranda el token y, sobre todo, congela un dato: si un administrador retira el rol OPERARIO a Luis, su token sigue diciendo que lo tiene hasta que caduque. Con quince minutos de vida es asumible; con veinticuatro horas, no. Es la limitación que anticipamos en el ejercicio 3 de 05-03, y la razón de fondo de que el token de acceso deba ser corto.

  1. Algoritmos de firma: HS256 frente a RS256

HS256 (HMAC-SHA256) RS256 (RSA-SHA256)
Tipo Simétrico: un secreto compartido Asimétrico: par de claves
Quién firma / verifica El mismo secreto para ambas cosas Privada firma, pública verifica
Tamaño de la firma y velocidad 32 bytes, muy rápido 256 bytes, firma lenta
Riesgo principal El secreto está en todos los que verifican Gestión del par de claves
Cuándo usarlo Un solo servicio emite y verifica Varios servicios verifican; proveedor externo

CicloUrbana usa HS256, porque hoy la misma aplicación emite y verifica los tokens y un secreto compartido es la solución más simple y perfectamente segura en ese escenario. Hay que cambiar a RS256 en cuanto más de un servicio necesite verificar: con HS256, quien puede verificar también puede emitir, así que compartir el secreto con cinco microservicios significa que cinco equipos pueden fabricar tokens de administrador. Con RS256 los servicios reciben solo la clave pública y verifican sin poder falsificar. Es el escenario del módulo 7, y el motivo de que los proveedores de identidad usen siempre algoritmos asimétricos.

La vulnerabilidad alg: none. Las primeras implementaciones de JWT aceptaban un token cuya cabecera declarase "alg":"none" y lo daban por válido sin firma. Un atacante solo tenía que quitar la firma y cambiar la cabecera. Otra variante consistía en cambiar RS256 por HS256 para que el servidor usara la clave pública como secreto HMAC —una clave que el atacante conoce—. La defensa es la misma en ambos casos: el algoritmo esperado lo decide el servidor, nunca el token. JJWT 0.12 lo hace correctamente si se usa verifyWith(clave), que fija el algoritmo a partir del tipo de clave. Nunca escribas código que lea alg de la cabecera para decidir cómo verificar.

  1. Firmado no es cifrado

Advertencia destacada. La carga útil de un JWT es legible por cualquiera que tenga el token: basta con decodificar Base64URL, sin secretos ni herramientas. La firma garantiza integridad y autenticidad, no confidencialidad.

Lo que nunca debe ir en un JWT:

  • Contraseñas o hashes de contraseñas.
  • DNI, dirección postal, teléfono, datos de salud o cualquier dato personal sensible.
  • Números de tarjeta, datos bancarios, claves de API, secretos internos o rutas de infraestructura.
  • Información comercial reservada, como el importe acumulado de los alquileres.

Lo que sí es razonable: el identificador del usuario, su correo si el servicio lo trata como identificador público, sus roles y las marcas de tiempo. La regla práctica: si no lo escribirías en una postal, no lo pongas en un JWT. Cuando de verdad hace falta confidencialidad existe JWE (JSON Web Encryption), que cifra la carga útil; es notablemente más complejo, y la solución habitual es más simple: no meter el dato en el token y consultarlo en el servidor cuando haga falta.

  1. JJWT y JwtProperties

<!-- Las tres con <version>0.12.6</version>: JJWT no lo gestiona el starter-parent -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId>
    <version>0.12.6</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version><scope>runtime</scope></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version><scope>runtime</scope></dependency>

Las tres son necesarias y el reparto es deliberado: jjwt-api contiene las interfaces y es la única con la que compilas; jjwt-impl y jjwt-jackson son implementaciones en ámbito runtime, lo que impide acoplar tu código a detalles internos. Si aparece un ClassNotFoundException sobre DefaultJwtBuilder, es que falta jjwt-impl. Y ojo: JJWT no está gestionado por el spring-boot-starter-parent, así que la versión hay que fijarla a mano y revisarla periódicamente (05-05).

La configuración, con @ConfigurationProperties tipadas como en 02-05:

# application.yml
ciclourbana:
  jwt:
    emisor: ciclourbana
    audiencia: ciclourbana-api
    expiracion: 15m                       # token de acceso: corto a propósito
    expiracion-refresco: 30d
    secreto: ${JWT_SECRETO}   # NUNCA un valor literal aquí
package com.ciclourbana.seguridad;

@ConfigurationProperties(prefix = "ciclourbana.jwt")
@Validated
public record JwtProperties(
        @NotBlank @Size(min = 43) String secreto,   // 43 caracteres Base64 = 256 bits
        @NotBlank String emisor,
        @NotBlank String audiencia,
        @NotNull Duration expiracion,
        @NotNull Duration expiracionRefresco) {

    public JwtProperties {
        if (expiracion.compareTo(Duration.ofHours(1)) > 0) {
            throw new IllegalArgumentException(
                    "El token de acceso no debe durar más de una hora; usa refresco");
        }
    }
}

Cuatro decisiones importantes:

El secreto viene de una variable de entorno. ${JWT_SECRETO} sin valor por defecto: si la variable no existe, la aplicación no arranca, que es exactamente lo que se quiere. Escribir un secreto por defecto en el YAML es peor que no tener seguridad, porque crea una falsa sensación de tenerla: acaba en Git, en la imagen Docker y en el portátil de todo el equipo, y ese mismo valor termina en producción con una frecuencia que asusta.

@Size(min = 43) no es arbitrario. HS256 exige una clave de al menos 256 bits, que en Base64 son 43 caracteres; JJWT rechaza claves más cortas con WeakKeyException, y validarlo al arrancar convierte ese fallo en tiempo de ejecución en un error de configuración comprensible. Un secreto adecuado se genera, nunca se inventa:

openssl rand -base64 48      # 64 caracteres Base64 = 384 bits
export JWT_SECRETO='...'   # el valor real, nunca en un fichero del repositorio

El constructor compacto valida la política, no solo el formato: que alguien configure expiracion: 24h no es un error de tipo pero sí de seguridad, y el arranque lo impide. Y el secreto se rota, como cualquier credencial: en producción vive en un gestor de secretos (Vault, AWS Secrets Manager, los secrets de Kubernetes) y se cambia periódicamente, sabiendo que rotarlo invalida todos los tokens en circulación.

  1. ServicioJwt: generar, leer y validar

package com.ciclourbana.seguridad;

@Service
public class ServicioJwt {

    private static final Logger log = LoggerFactory.getLogger(ServicioJwt.class);

    private final JwtProperties propiedades;
    private final SecretKey clave;
    private final Clock reloj;   // el bean Clock de 03-03

    public ServicioJwt(JwtProperties propiedades, Clock reloj) {
        this.propiedades = propiedades;
        this.reloj = reloj;
        // Decodifica el secreto Base64 y verifica que tenga fuerza suficiente
        this.clave = Keys.hmacShaKeyFor(Decoders.BASE64.decode(propiedades.secreto()));
    }

    /** Emite un token de acceso para un usuario ya autenticado. */
    public String generarToken(UsuarioAutenticado usuario) {
        Instant ahora = Instant.now(reloj);
        return Jwts.builder()
                .issuer(propiedades.emisor())
                .audience().add(propiedades.audiencia()).and()
                .subject(usuario.getUsername())
                .id(UUID.randomUUID().toString()).issuedAt(Date.from(ahora))   // jti, iat
                .expiration(Date.from(ahora.plus(propiedades.expiracion())))
                .claim("idUsuario", usuario.getIdUsuario())
                .claim("roles", usuario.getAuthorities().stream()
                        .map(GrantedAuthority::getAuthority).toList())
                .signWith(clave, Jwts.SIG.HS256)
                .compact();
    }

    /** Verifica firma, emisor, audiencia y expiración. Lanza si algo falla. */
    public Claims extraerClaims(String token) {
        return Jwts.parser()
                .verifyWith(clave)                    // fija el algoritmo: no lo lee del token
                .requireIssuer(propiedades.emisor())
                .requireAudience(propiedades.audiencia())
                .clockSkewSeconds(30)                 // tolerancia de reloj entre máquinas
                .build().parseSignedClaims(token).getPayload();
    }

    /** Versión que no lanza: útil en el filtro, donde un token inválido es rutina. */
    public Optional<Claims> validar(String token) {
        try {
            return Optional.of(extraerClaims(token));
        } catch (ExpiredJwtException e) {
            log.debug("Token caducado");                  // frecuente: NO es un error
        } catch (SecurityException | MalformedJwtException e) {
            log.warn("Token con firma inválida o malformado");    // esto sí es sospechoso
        } catch (JwtException | IllegalArgumentException e) {
            log.warn("Token no válido: {}", e.getClass().getSimpleName());
        }
        return Optional.empty();   // nunca se registra el token en el log
    }
}

Cinco detalles que hacen que este código sea correcto y no solo funcional:

  1. verifyWith(clave) fija el algoritmo a partir del tipo de clave. Es lo que cierra la puerta a alg: none y a la confusión RS256/HS256 del apartado 5.
  2. requireIssuer y requireAudience rechazan tokens ajenos. Se olvidan casi siempre.
  3. clockSkewSeconds(30) tolera el desfase de reloj entre máquinas. Sin él, dos servidores con dos segundos de diferencia producen rechazos intermitentes imposibles de reproducir. Y el Clock inyectado —el bean de 03-03— hará posible probar la caducidad en el módulo 6 sin esperar quince minutos.
  4. Un token caducado se registra en DEBUG; una firma inválida, en WARN. Lo primero es el funcionamiento normal; lo segundo puede ser un ataque, y mezclarlos hace inútil el log (09-05).
  5. Nunca se registra el token: un token en un log es una credencial en un log. Y .audience().add(...).and() es la API fluida de JJWT 0.12, que cambió respecto a 0.11, así que mucho código de internet no compilará contra esta versión.

  1. FiltroAutenticacionJwt

El filtro es la pieza que convierte una cabecera HTTP en un usuario autenticado. Hereda de OncePerRequestFilter, igual que el FiltroTraza de 03-06, para garantizar una sola ejecución por petición aunque haya reenvíos internos.

package com.ciclourbana.seguridad;

@Component
public class FiltroAutenticacionJwt extends OncePerRequestFilter {

    private static final String CABECERA = "Authorization";
    private static final String PREFIJO = "Bearer ";
    private final ServicioJwt servicioJwt;   // constructor omitido

    @Override
    protected void doFilterInternal(HttpServletRequest peticion,
                                    HttpServletResponse respuesta,
                                    FilterChain cadena) throws ServletException, IOException {

        extraerToken(peticion).flatMap(servicioJwt::validar)
                .ifPresent(claims -> autenticar(claims, peticion));
        // SIEMPRE se continúa: rechazar es trabajo del AuthorizationFilter
        cadena.doFilter(peticion, respuesta);
    }

    private Optional<String> extraerToken(HttpServletRequest peticion) {
        String cabecera = peticion.getHeader(CABECERA);
        return (cabecera != null && cabecera.startsWith(PREFIJO))
                ? Optional.of(cabecera.substring(PREFIJO.length()).trim()) : Optional.empty();
    }

    @SuppressWarnings("unchecked")
    private void autenticar(Claims claims, HttpServletRequest peticion) {
        if (SecurityContextHolder.getContext().getAuthentication() != null) return;

        List<String> roles = claims.get("roles", List.class);
        var autoridades = roles.stream().map(SimpleGrantedAuthority::new).toList();
        var usuario = new UsuarioAutenticado(claims.get("idUsuario", Integer.class).longValue(),
                                             claims.getSubject(), autoridades);

        var autenticacion = new UsernamePasswordAuthenticationToken(
                usuario, null, autoridades);     // credenciales: null, ya está autenticado
        autenticacion.setDetails(new WebAuthenticationDetailsSource().buildDetails(peticion));
        SecurityContextHolder.getContext().setAuthentication(autenticacion);
    }
}

Cinco decisiones que conviene entender:

El filtro nunca rechaza la petición. Si no hay token, o es inválido, simplemente no autentica y deja continuar la cadena: quien decide si esa petición necesitaba autenticación es el AuthorizationFilter de 05-01, y quien produce el 401 es el AuthenticationEntryPoint de 05-03. Esta separación es la que permite que GET /api/v1/estaciones siga siendo público mientras /api/v1/alquileres no lo es, con el mismo filtro.

No se consulta la base de datos: toda la información —id, correo, roles— viene del token. Es lo que hace el modelo sin estado y rápido, y también lo que congela los roles hasta la caducidad; la alternativa, cargar el UserDetails en cada petición, es más segura y sacrifica la mayor parte de la ventaja.

Se comprueba si ya hay autenticación antes de escribir en el contexto, para no pisar la de otro mecanismo. UsuarioAutenticado necesita un segundo constructor que reciba id, correo y autoridades, sin entidad ni hash: getPassword() devolverá null y no pasa nada, porque en este camino nadie compara contraseñas. Y se guardan los details con la IP: información valiosa para la auditoría de 05-03.

  1. Registrar el filtro en la cadena de seguridad

@Bean
SecurityFilterChain cadenaApi(HttpSecurity http,
                              FiltroAutenticacionJwt filtroJwt,
                              PuntoEntradaNoAutenticado puntoEntrada,
                              ManejadorAccesoDenegado accesoDenegado) throws Exception {
    http
        .securityMatcher("/api/**")
        .csrf(csrf -> csrf.disable())                 // justificado en 05-02
        .cors(Customizer.withDefaults())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .httpBasic(AbstractHttpConfigurer::disable)   // adiós a la contraseña en cada petición
        .formLogin(AbstractHttpConfigurer::disable)
        .logout(AbstractHttpConfigurer::disable)      // no hay sesión que cerrar
        .authorizeHttpRequests(auth -> auth
            .requestMatchers(HttpMethod.POST, "/api/v1/auth/**").permitAll()
            .anyRequest().denyAll())          // ... y el resto del mapa de 05-02
        .exceptionHandling(ex -> ex.authenticationEntryPoint(puntoEntrada)
                                   .accessDeniedHandler(accesoDenegado))
        .addFilterBefore(filtroJwt, UsernamePasswordAuthenticationFilter.class);
    return http.build();
}

addFilterBefore(filtroJwt, UsernamePasswordAuthenticationFilter.class) coloca el filtro en una posición concreta de la cadena de 05-01: después de SecurityContextHolderFilter y CorsFilter —así el contexto está limpio y las peticiones de sondeo CORS ya se han resuelto— y antes de ExceptionTranslationFilter y AuthorizationFilter, que es lo imprescindible: cuando la autorización evalúe las reglas, el contexto ya debe contener al usuario. Y aquí desaparecen httpBasic y formLogin, tal como anunciamos en 05-02: a partir de ahora la única forma de autenticarse contra la API es un token.

  1. POST /api/v1/auth/login

public record LoginRequest(@NotBlank @Email String correo, @NotBlank String contrasena) {}

public record TokenResponse(String tokenAcceso, String tokenRefresco,
                            String tipo, long expiraEnSegundos) {}

@Service
public class ServicioAutenticacion {

    private final AuthenticationManager gestorAutenticacion;   // bean de 05-03
    private final ServicioJwt servicioJwt;
    private final ServicioTokenRefresco servicioRefresco;
    private final JwtProperties propiedades;                   // constructor omitido

    public TokenResponse autenticar(LoginRequest peticion) {
        // Delega TODA la validación en el DaoAuthenticationProvider de 05-03:
        // hash, cuenta activa, bloqueo y protección contra temporización
        Authentication autenticacion = gestorAutenticacion.authenticate(
                new UsernamePasswordAuthenticationToken(
                        peticion.correo().trim().toLowerCase(Locale.ROOT),
                        peticion.contrasena()));

        var usuario = (UsuarioAutenticado) autenticacion.getPrincipal();
        return new TokenResponse(
                servicioJwt.generarToken(usuario),
                servicioRefresco.emitir(usuario.getIdUsuario()).token(),
                "Bearer",
                propiedades.expiracion().toSeconds());
    }
}

El controlador es un @PostMapping("/login") en el AutenticacionController de 05-03 que recibe @Valid @RequestBody LoginRequest y devuelve 200 OK con el TokenResponse.

Tres puntos. La autenticación no se reimplementa: se delega en el AuthenticationManager, que ya sabe comparar hashes en tiempo constante, comprobar si la cuenta está activa y publicar los eventos de fallo; reescribir aquí un if (codificador.matches(...)) perdería todo eso. Si falla lanza BadCredentialsException, que hay que traducir a un 401 uniforme en ManejadorGlobalExcepciones —este sí lo ve, porque ocurre dentro de un controlador—. Y la respuesta no revela nada del usuario: solo tokens.

  1. Tokens de refresco: rotación y revocación

Hay una tensión evidente. Un token de acceso corto limita el daño de un robo, pero obliga al ciudadano a escribir su contraseña cada quince minutos. Un token largo es cómodo y peligroso.

La solución son dos tokens con papeles distintos:

Token de acceso Token de refresco
Formato y duración JWT firmado, 15 minutos Cadena aleatoria opaca, 30 días
Dónde se usa En cada petición a la API Solo en /api/v1/auth/refresco
¿El servidor lo guarda? ¿Se revoca? No y no Sí, en base de datos; revocable al instante

El token de refresco no es un JWT, y es deliberado: como el servidor lo guarda de todos modos para poder revocarlo, no hay ventaja en que sea autocontenido, y una cadena aleatoria larga es más corta, más opaca y no revela nada.

-- V6__crear_tokens_refresco.sql
-- (V5 normalizó los correos, en el ejercicio 2 de 05-03)
CREATE SEQUENCE tokens_refresco_id_seq INCREMENT BY 50 START WITH 1;

CREATE TABLE tokens_refresco (
    id          BIGINT       NOT NULL,
    usuario_id  BIGINT       NOT NULL,
    token_hash  VARCHAR(64)  NOT NULL,   -- SHA-256 del token, nunca el token
    expira_en   TIMESTAMPTZ  NOT NULL,
    revocado_en TIMESTAMPTZ,
    creado_en   TIMESTAMPTZ  NOT NULL,
    ip_origen   VARCHAR(45),
    CONSTRAINT pk_tokens_refresco      PRIMARY KEY (id),
    CONSTRAINT uk_tokens_refresco_hash UNIQUE (token_hash),
    CONSTRAINT fk_tokens_refresco_user FOREIGN KEY (usuario_id)
        REFERENCES usuarios (id) ON DELETE CASCADE
);
CREATE INDEX ix_tokens_refresco_usuario ON tokens_refresco (usuario_id);
CREATE INDEX ix_tokens_refresco_expira  ON tokens_refresco (expira_en);

Se guarda el SHA-256 del token, no el token, por el mismo razonamiento que con las contraseñas: si alguien lee la tabla, no obtiene credenciales utilizables. Aquí basta un hash rápido, porque el token es una cadena aleatoria de 256 bits y no una contraseña adivinable por diccionario. El endpoint de refresco, con rotación:

@Transactional
public TokenResponse refrescar(String tokenPresentado) {
    String hash = sha256(tokenPresentado);
    TokenRefresco guardado = repositorio.findByTokenHash(hash)
            .orElseThrow(() -> new BadCredentialsException("Token de refresco no válido"));
    if (guardado.getRevocadoEn() != null) {   // reutilización: señal de robo, se corta todo
        log.error("REUTILIZACIÓN de token de refresco. Usuario {}", guardado.getUsuarioId());
        repositorio.revocarTodosDelUsuario(guardado.getUsuarioId(), Instant.now(reloj));
        throw new BadCredentialsException("Token de refresco no válido");
    }
    if (guardado.getExpiraEn().isBefore(Instant.now(reloj)))
        throw new BadCredentialsException("Token de refresco no válido");

    guardado.setRevocadoEn(Instant.now(reloj));           // rotación: el viejo muere
    var usuario = cargarUsuario(guardado.getUsuarioId()); // roles frescos de la BD
    return new TokenResponse(servicioJwt.generarToken(usuario),
                             emitir(usuario.getIdUsuario()).token(),
                             "Bearer", propiedades.expiracion().toSeconds());
}

Cuatro ideas clave:

Rotación: cada uso del token de refresco lo invalida y emite uno nuevo. Reduce la ventana de un token robado y, sobre todo, hace detectable el robo.

Detección de reutilización: si llega un token ya revocado, o bien es el ladrón usando uno que la víctima ya rotó, o bien al revés; no hay forma de saber cuál, así que se revocan todos los tokens de refresco de ese usuario y se le obliga a autenticarse de nuevo. Es molesto y es lo correcto.

Los roles se recargan de la base de datos, y es el momento en que el cambio de privilegios del ejercicio 3 de 05-03 surte efecto: como máximo quince minutos después. El mensaje de error es siempre el mismo, no exista el token, esté revocado o haya caducado. Y falta una pieza operativa: una tarea programada que borre los caducados, o la tabla crece sin límite (07-03).

  1. Cerrar sesión en un mundo sin estado

POST /api/v1/auth/logout no puede invalidar el token de acceso. Está firmado, es válido y el servidor no guarda nada sobre él. Las opciones reales:

Estrategia Efecto Coste
Borrar el token en el cliente El cliente deja de enviarlo Nulo. No protege si ya fue robado
Revocar el token de refresco En ≤15 min el acceso muere Bajo. La opción de CicloUrbana
Lista de revocación por jti Inmediato Un almacén consultado en cada petición: deja de ser sin estado

CicloUrbana implementa el cierre de sesión como revocación del token de refresco: el token de acceso sigue valiendo hasta quince minutos y luego no se puede renovar. Es una decisión consciente, y hay que ser honestos sobre lo que significa: durante esos quince minutos, un token robado sigue funcionando.

Cuando eso no es tolerable —una operación bancaria, un cierre de sesión por sospecha de compromiso— la solución es una lista de revocación de los jti en una caché rápida como Redis, con expiración automática al caducar el token; consultarla en cada petición reintroduce estado, pero acotado, y la caché distribuida se trata en 09-02. En cualquier caso, la defensa principal sigue siendo la expiración corta: todo lo demás son mitigaciones.

  1. Dónde guarda el token el cliente

Es la parte del sistema que el backend no controla y donde más incidentes reales se producen.

Almacenamiento Vulnerable a XSS Vulnerable a CSRF Notas
localStorage / sessionStorage Sí, totalmente No Legible por cualquier JS de la página
Cookie HttpOnly + Secure + SameSite=Strict No Sí, requiere CSRF El JS no puede leerla
Memoria del JS (una variable) Parcialmente No Se pierde al recargar; el refresco en cookie lo mitiga
Llavero del sistema (móvil) No aplica No aplica La mejor opción en una app nativa

Advertencia. localStorage es el consejo más repetido de internet y el peor. Cualquier XSS —una dependencia de JavaScript comprometida, un campo mal escapado— permite leer el token y suplantar al usuario desde otra máquina. Una cookie HttpOnly no es legible por JavaScript ni siquiera con XSS.

La app móvil de CicloUrbana, que es el cliente principal, usa el llavero del sistema: Keychain en iOS, EncryptedSharedPreferences o Keystore en Android. Para el panel web del ayuntamiento la recomendación es la cookie HttpOnly; Secure; SameSite=Strict con protección CSRF reactivada —recuerda la advertencia de 05-02: al volver la credencial a una cookie, la condición que permitía desactivar CSRF deja de cumplirse—, o bien el patrón mixto de token de acceso en memoria y refresco en cookie HttpOnly. Y en cualquier caso, HTTPS obligatorio: sin TLS todo lo anterior es irrelevante, porque el token viaja legible por la red (05-05).

  1. Documentar el esquema en OpenAPI y la alternativa para producción

El Swagger UI de 03-07 dejó de funcionar contra los endpoints protegidos: no tiene dónde escribir el token. Se arregla declarando el esquema de seguridad en ConfiguracionOpenApi:

@Bean
OpenAPI apiCicloUrbana(...) {
    return new OpenAPI()
            .info(...)   // igual que en 03-07
            .addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
            .components(new Components().addSecuritySchemes("bearerAuth",
                    new SecurityScheme().type(SecurityScheme.Type.HTTP)
                            .scheme("bearer").bearerFormat("JWT")
                            .description("Token obtenido en POST /api/v1/auth/login")));
}

Aparece entonces el botón Authorize en la interfaz y springdoc añade la cabecera a todas las llamadas; los endpoints públicos se marcan con @SecurityRequirements vacío para que la documentación no mienta.

La alternativa recomendada para producción

Todo el código de esta lección es una implementación propia: excelente para entender el mecanismo, y en un sistema real conviene no escribirlo. Spring ofrece un módulo mantenido y auditado:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # El proveedor publica sus claves públicas; Spring las descarga y las rota solo
          issuer-uri: https://identidad.ribalta.es/realms/ciclourbana
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))

Con esas líneas desaparecen ServicioJwt, FiltroAutenticacionJwt y la gestión de secretos: el BearerTokenAuthenticationFilter de 05-01 se encarga usando Nimbus por debajo, y las claves públicas se obtienen del JWKS del proveedor y se rotan solas.

Implementación propia (esta lección) Resource Server + proveedor
Código que mantienes ~200 líneas de seguridad Prácticamente ninguna
Emisión de tokens y rotación de claves Tuya, manual Del proveedor (Keycloak, Auth0, Cognito), automática vía JWKS
Segundo factor, OIDC, SSO Habría que implementarlo Incluido
Auditoría del código Tu responsabilidad Del proveedor
Infraestructura extra Ninguna Un servicio más

El criterio: si el sistema tiene un solo servicio, un cliente y requisitos sencillos, la implementación propia con una librería sólida es aceptable. En cuanto aparezcan varios servicios, inicio de sesión con proveedores externos, segundo factor o requisitos de cumplimiento normativo, usa un proveedor de identidad. El coste de mantener seguridad propia crece mucho más rápido de lo que parece.

  1. Probar el flujo completo con curl

# 1. Login (tras el registro de 05-03): la contraseña viaja UNA sola vez
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"correo":"[email protected]","contrasena":"clave-larga-ej"}' | jq -r .tokenAcceso)

# 3 y 4. Sin token y con token
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/v1/alquileres   # 401
curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/alquileres         # 200

# 5. Inspeccionar la carga útil (sin secreto: demuestra que NO va cifrada)
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
# {"iss":"ciclourbana","sub":"[email protected]","idUsuario":42,"roles":[...],...}

# 6. Marta es CIUDADANO: la gestión de estaciones le está vedada
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"nombre":"Mercado Viejo","capacidad":20}' \
  http://localhost:8080/api/v1/estaciones                                          # 403

# 7. Token manipulado: cambiar un solo carácter rompe la firma
curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer ${TOKEN}x" http://localhost:8080/api/v1/alquileres     # 401

Los pasos 5 y 7 son los importantes: el 5 demuestra que el token es legible por cualquiera —de ahí la advertencia del apartado 6— y el 7, que no es modificable.

Errores Comunes y Consejos

Poner datos sensibles en la carga útil. Va firmada, no cifrada: un DNI en un JWT es un DNI público.

Escribir el secreto en application.yml, o usar uno corto e inventado. Un secreto en el YAML acaba en Git, en la imagen y en el portátil de todo el equipo; y misecreto123 no llega a 256 bits. Variable de entorno sin valor por defecto, generado con openssl rand -base64 48. Y nada de tokens de acceso de 24 horas «para que no moleste»: multiplican por 96 la ventana de un robo y congelan los roles todo ese tiempo.

Que el filtro devuelva 401 directamente, o registrarlo en la posición equivocada de la cadena. Lo primero rompe los endpoints públicos —el filtro autentica si puede, y la decisión es del AuthorizationFilter—; lo segundo, si queda tras AuthorizationFilter, hace que todo devuelva 401.

No validar iss ni aud, con lo que un token emitido por otro sistema con el mismo secreto sería aceptado; o leer alg del token para decidir cómo verificar, que es la vulnerabilidad alg: none. El algoritmo lo fija el servidor.

Guardar el token de refresco en claro en la base de datos, o registrar cualquier token en un log aunque sea truncado. Un token es una credencial; guarda su SHA-256 y no lo escribas nunca.

Consejo: expiración corta más refresco con rotación —15 minutos de acceso, 30 días de refresco rotatorio con detección de reutilización— es el equilibrio que funciona. Y usa el Clock inyectado en ServicioJwt: en el módulo 6 podrás probar la caducidad adelantando el reloj en lugar de esperar.

Ejercicios

Ejercicio 1

Un token de CicloUrbana tiene esta carga útil. Detecta cuatro problemas de seguridad o diseño y propón la versión corregida.

{ "sub": "[email protected]", "nombre": "Marta Aguiló", "dni": "12345678Z",
  "contrasenaHash": "$2a$10$N9qo8uLOickgx2ZMRZoMye...", "idUsuario": 42,
  "roles": ["ROLE_CIUDADANO"], "importeAcumulado": 127.50,
  "iat": 1773561600, "exp": 1774166400 }

Ejercicio 2

Implementa ServicioJwt.validar(String) de forma que distinga tres resultados —token válido, token caducado y token inválido— mediante un tipo propio en lugar de un Optional, y explica qué debe hacer el filtro con cada uno y a qué nivel de log corresponde.

Ejercicio 3

Diseña el flujo completo para este requisito del ayuntamiento: «cuando un ciudadano cambia su contraseña, todas sus sesiones abiertas en otros dispositivos deben cerrarse inmediatamente». Ten en cuenta que el token de acceso no se puede revocar.

Soluciones

Solución 1

Problema 1 — dni. Dato personal identificativo, legible por cualquiera con el token y sujeto al RGPD. Fuera. Problema 2 — contrasenaHash, el fallo más grave: entrega el hash a cualquiera que capture el token, permitiendo un ataque de diccionario sin límite de intentos y sin dejar rastro en el servidor. Fuera, siempre.

Problema 3 — importeAcumulado. Dato comercial reservado y, además, un dato que cambia: quedaría congelado hasta la caducidad y mostraría cifras obsoletas si el cliente se fiara de él. Se consulta por API. Problema 4 — expiración de siete días (exp - iat = 604 800): un token robado vale una semana, y los cambios de rol tardan una semana en aplicarse. Faltan además iss, aud y jti, sin los cuales no se puede validar la procedencia ni revocar por identificador.

{ "iss": "ciclourbana", "aud": "ciclourbana-api", "sub": "[email protected]",
  "idUsuario": 42, "roles": ["ROLE_CIUDADANO"],
  "iat": 1773561600, "exp": 1773562500,
  "jti": "8f2a4c31-5b7e-4d19-9c02-6ea3f1b0d47c" }

Regla para decidir qué entra: solo lo que se necesita en cada petición para autorizar, no cambia durante la vida del token y no sería un problema en una postal. El nombre podría quedarse si la app lo muestra —no es sensible—, pero engorda el token: es preferible pedirlo una vez a /api/v1/usuarios/yo.

Solución 2

public sealed interface ResultadoValidacion {
    record Valido(Claims claims) implements ResultadoValidacion {}
    record Caducado(String correo) implements ResultadoValidacion {}
    record Invalido(String motivo) implements ResultadoValidacion {}
}

public ResultadoValidacion validar(String token) {
    try {
        return new ResultadoValidacion.Valido(extraerClaims(token));
    } catch (ExpiredJwtException e) {
        // La excepción de JJWT conserva los claims: útil para el log y para sugerir refresco
        return new ResultadoValidacion.Caducado(e.getClaims().getSubject());
    } catch (SecurityException e) {
        return new ResultadoValidacion.Invalido("firma");
    } catch (MalformedJwtException e) {
        return new ResultadoValidacion.Invalido("formato");
    } catch (JwtException | IllegalArgumentException e) {
        return new ResultadoValidacion.Invalido(e.getClass().getSimpleName());
    }
}

// En el filtro, con switch de patrones de Java 21:
switch (servicioJwt.validar(token)) {
    case ResultadoValidacion.Valido v -> autenticar(v.claims(), peticion);
    case ResultadoValidacion.Caducado c -> {          // pista para el cliente
        log.debug("Token caducado de {}", c.correo());
        respuesta.setHeader("X-Motivo-Auth", "token-caducado");
    }
    case ResultadoValidacion.Invalido i -> log.warn("Token rechazado ({})", i.motivo());
}

Qué hace el filtro con cada caso. Válido: autentica. Caducado: no autentica, pero tampoco es un error; es el funcionamiento normal cada quince minutos, va a DEBUG, y la cabecera de pista permite a la app móvil distinguir «renueva el token» de «vuelve a iniciar sesión» sin exponer nada. Inválido: no autentica y va a WARN, porque una firma incorrecta es un intento de manipulación o un cliente roto, y un pico de estos eventos debe disparar una alerta (09-05). La ventaja del tipo sellado sobre el Optional es que es imposible olvidar un caso: el switch sobre una interfaz sealed debe ser exhaustivo o no compila, y en las pruebas del módulo 6 se puede afirmar sobre el motivo exacto.

Solución 3

El problema: el token de acceso de los otros dispositivos es válido y no hay nada en el servidor que lo invalide; revocar los tokens de refresco cierra la puerta futura, pero deja hasta quince minutos de acceso vigente. Solución completa, en cuatro piezas. 1. Marca de invalidación en el usuario, con una migración V7__anadir_invalidacion_tokens.sql que ejecuta ALTER TABLE usuarios ADD COLUMN tokens_invalidos_antes_de TIMESTAMPTZ;. 2. Al cambiar la contraseña, en la misma transacción: guardar el hash nuevo, poner tokensInvalidosAntesDe = ahora y revocar todos los tokens de refresco del usuario. 3. Comprobación en el filtro, aprovechando que el iat del token dice cuándo se emitió: si es anterior a la marca, el token muere.

Instant emitido = claims.getIssuedAt().toInstant();
Instant corte = servicioUsuarios.invalidacionDe(claims.get("idUsuario", Integer.class));
if (corte != null && emitido.isBefore(corte)) return;   // no autenticar

4. El coste, y cómo pagarlo. Esto reintroduce una consulta por petición, justo lo que el modelo sin estado evitaba. Tres formas de amortiguarlo, de menor a mayor esfuerzo: cachear la marca por usuario con expiración corta (09-02); mantener en caché solo los usuarios con invalidación reciente, que son poquísimos, y tratar la ausencia como «sin invalidación»; o incluir la marca como claim en el token, aunque entonces solo protege a partir del siguiente refresco y no resuelve el requisito.

La lección de fondo: el requisito «cerrar sesión inmediatamente en todas partes» es incompatible con la autenticación puramente sin estado. Toda solución paga con estado en el servidor. Lo honesto es reconocerlo, elegir dónde se paga y dejarlo documentado, no fingir que el JWT lo resuelve todo. Y conviene preguntar antes si «inmediatamente» significa de verdad cero segundos o si quince minutos son aceptables: la respuesta cambia por completo la arquitectura.

Conclusión

CicloUrbana autentica ya como una API moderna. Sabes por qué una aplicación móvil necesita autenticación sin estado —el cliente no es un navegador, la sesión no sobrevive al balanceador y la restricción REST de 03-01 lo exige— y has comparado sesión y token con honestidad, sin ocultar los tres inconvenientes reales del JWT: no se revoca con facilidad, ocupa espacio en cada petición y es peligroso si el cliente lo almacena mal. Sabes también que para una web clásica con vistas en el servidor la sesión con cookie sigue siendo la opción más simple y más segura: CicloUrbana elige el token por sus requisitos, no por moda.

Conoces un JWT por dentro: cabecera, carga útil y firma en Base64URL, el HMAC que ata las dos primeras partes al secreto del servidor, y el hecho central de que Base64URL no es cifrado. Manejas los claims registrados —iss, sub, aud, exp, iat, jti— y los dos propios del proyecto, roles e idUsuario, sabiendo que cada uno agranda el token y congela un dato hasta la caducidad. Has comparado HS256 con RS256 y sabes cuándo obliga a cambiar la arquitectura: en cuanto un segundo servicio tenga que verificar, porque con un secreto compartido quien verifica también puede emitir. Y conoces la vulnerabilidad alg: none y su defensa: el algoritmo lo decide el servidor, nunca el token.

Has implementado el sistema completo con JJWT 0.12: las tres dependencias con sus ámbitos, JwtProperties validadas con un secreto que llega por variable de entorno y sin valor por defecto —de modo que la aplicación no arranca si falta—, con longitud mínima de 43 caracteres Base64 y una comprobación de política que impide configurar tokens de más de una hora. ServicioJwt genera, extrae y valida con verifyWith, requireIssuer, requireAudience, tolerancia de reloj y un Clock inyectado que hará posible probar la caducidad en el módulo 6. FiltroAutenticacionJwt, un OncePerRequestFilter registrado con addFilterBefore delante de UsernamePasswordAuthenticationFilter, nunca rechaza: autentica si puede y deja la decisión al AuthorizationFilter, que es justo lo que permite convivir endpoints públicos y protegidos en la misma cadena. Y con POST /api/v1/auth/login delegando en el AuthenticationManager de 05-03, HTTP Basic y el formulario han desaparecido de la configuración.

Has añadido tokens de refresco con la tabla tokens_refresco de la migración V6, que guarda el SHA-256 del token y no el token, con rotación en cada uso, detección de reutilización que revoca todas las sesiones del usuario ante la señal de robo y recarga de roles desde la base de datos en cada refresco. Y has mirado de frente los dos problemas que el marketing del JWT suele esconder: cerrar sesión en un mundo sin estado es revocar el refresco y aceptar hasta quince minutos de acceso vigente, o pagar con estado mediante una lista de revocación por jti; y el almacenamiento en el cliente, donde localStorage es el consejo más repetido y el peor, frente al llavero del sistema en la app móvil y la cookie HttpOnly; Secure; SameSite —con CSRF reactivado— en el panel web. El Swagger UI de 03-07 vuelve a funcionar con su botón Authorize, y conoces la alternativa madura para producción: spring-boot-starter-oauth2-resource-server con un proveedor de identidad que rota sus claves por JWKS y trae SSO y segundo factor de serie.

Queda un agujero, y es el mismo que dejamos anotado en 05-01 y en 05-03. Marta tiene un token válido con el rol ROLE_CIUDADANO, así que POST /api/v1/alquileres/9/finalizar pasa todas las reglas de la cadena: la ruta es legítima y el rol es correcto. Pero el alquiler número 9 es de otro ciudadano. Ninguna regla basada en URL puede expresar «solo tus propios alquileres», porque la respuesta no depende de la ruta ni del rol, sino del dato. Es el riesgo A01/BOLA del OWASP Top 10, el más explotado de las APIs REST. En 05-05, Seguridad a Nivel de Método y Endurecimiento de la API, bajaremos la seguridad hasta la capa de servicio con @EnableMethodSecurity, @PreAuthorize y expresiones SpEL, escribiremos un evaluador de permisos propio para la regla de los alquileres, y cerraremos el módulo endureciendo la API entera: CORS por entorno, limitación de tasa, HTTPS obligatorio, ocultación de la documentación en producción, auditoría de eventos de seguridad, revisión de dependencias vulnerables y una lista de comprobación antes de producción.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados