Autenticación completa en Spring Boot: JWT, OAuth2 y autenticación multifactor paso a paso

La seguridad en aplicaciones Spring Boot ha cambiado bastante en los últimos años. Spring Security 6 eliminó WebSecurityConfigurerAdapter, que durante años fue la forma habitual de configurar la seguridad, y lo sustituyó por beans de tipo SecurityFilterChain. El cambio es para mejor: la configuración es más explícita, más fácil de testear y más sencilla de combinar cuando tienes varios módulos de seguridad activos a la vez.

En esta guía montas una autenticación completa: JWT para las peticiones, refresh tokens para renovar la sesión sin pedir contraseña, OAuth2 para delegar la autenticación en un proveedor externo y MFA con TOTP por si quieres añadir una capa extra. Cada bloque es independiente; puedes implementar solo los que necesites.

Spring Security 6: SecurityFilterChain en lugar de herencia

Antes extendías WebSecurityConfigurerAdapter y sobreescribías métodos. Ahora declaras un bean SecurityFilterChain en una clase de configuración:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/auth/**").permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

Deshabilitar CSRF tiene sentido en APIs stateless porque no hay cookies de sesión que robar. La política STATELESS le indica a Spring que no cree ni use sesiones HTTP; cada petición se valida de forma independiente a través del JWT.

JWT: estructura y generación

Un JSON Web Token tiene tres partes separadas por puntos: cabecera, payload y firma. La cabecera indica el algoritmo (normalmente HS256 o RS256). El payload lleva las claims: sub (identificador del usuario), iat (issued at), exp (expiración). La firma garantiza que nadie ha modificado el token.

Para trabajar con JWT en Spring Boot, añade la dependencia de JJWT:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.5</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.5</version>
    <scope>runtime</scope>
</dependency>

El servicio de JWT se encarga de generar y validar tokens:

@Service
public class JwtService {

    @Value("${jwt.secret}")
    private String secretKey;

    private static final long EXPIRATION_MS = 15 * 60 * 1000; // 15 minutos

    public String generateToken(UserDetails user) {
        return Jwts.builder()
                .subject(user.getUsername())
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
                .signWith(getSigningKey())
                .compact();
    }

    public String extractUsername(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getSubject();
    }

    public boolean isValid(String token, UserDetails user) {
        String username = extractUsername(token);
        return username.equals(user.getUsername())
                && !isExpired(token);
    }

    private boolean isExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    private Date extractExpiration(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload()
                .getExpiration();
    }

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

Mantén el JWT con una expiración corta: 15 minutos es un buen punto de partida. Así reduces la ventana de exposición si alguien intercepta un token.

Flujo completo: login hasta petición autenticada

  1. El cliente envía usuario y contraseña a POST /auth/login.
  2. Spring autentica contra UserDetailsService.
  3. Si la credencial es válida, generas un JWT de acceso (15 min) y un refresh token (7 días).
  4. El cliente guarda el JWT y lo envía en cada petición: Authorization: Bearer <token>.
  5. El filtro JwtAuthFilter intercepta cada petición, valida el token y carga el usuario en el contexto de seguridad.
@Component
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            chain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7);
        String username = jwtService.extractUsername(token);

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails user = userDetailsService.loadUserByUsername(username);
            if (jwtService.isValid(token, user)) {
                UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        chain.doFilter(request, response);
    }
}

Refresh tokens: renovar sin pedir contraseña

Guardar el refresh token en memoria no es seguro: si la aplicación se reinicia, todos los usuarios tienen que volver a autenticarse. Y si usas varias instancias, un token emitido por una instancia no existe en la memoria de las demás.

Guarda los refresh tokens en base de datos con una tabla simple:

CREATE TABLE refresh_tokens (
    id BIGSERIAL PRIMARY KEY,
    token VARCHAR(255) NOT NULL UNIQUE,
    username VARCHAR(100) NOT NULL,
    expires_at TIMESTAMP NOT NULL,
    revoked BOOLEAN DEFAULT FALSE
);

Al renovar, rota el refresh token: invalida el anterior e issuea uno nuevo. Así detectas si alguien está reutilizando un token que debería haberse descartado.

OAuth2 como resource server

Si tu aplicación no gestiona las credenciales sino que delega en Keycloak, Auth0 u otro proveedor, Spring Boot puede actuar como resource server: valida los tokens JWT que emite el proveedor externo sin gestionar el login.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://tu-keycloak.com/realms/mi-realm
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/public/**").permitAll()
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
    return http.build();
}

Spring descarga automáticamente las claves públicas del proveedor a través del endpoint /.well-known/openid-configuration y las usa para verificar la firma de cada token.

MFA con TOTP: Google Authenticator en tu app

TOTP (Time-based One-Time Password) genera códigos de 6 dígitos que cambian cada 30 segundos. El usuario configura su aplicación de autenticación (Google Authenticator, Authy…) escaneando un QR con el secreto compartido.

La librería dev.samstevens.totp facilita la integración:

<dependency>
    <groupId>dev.samstevens.totp</groupId>
    <artifactId>totp-spring-boot-starter</artifactId>
    <version>1.7.1</version>
</dependency>
@Service
public class MfaService {

    private final SecretGenerator secretGenerator;
    private final CodeVerifier codeVerifier;
    private final QrDataFactory qrDataFactory;
    private final QrGenerator qrGenerator;

    public String generateSecret() {
        return secretGenerator.generate();
    }

    public String getQrCodeUri(String secret, String username) throws Exception {
        QrData data = qrDataFactory.newBuilder()
                .label(username)
                .secret(secret)
                .issuer("MiAplicacion")
                .build();
        return "data:image/png;base64," +
               Base64.getEncoder().encodeToString(qrGenerator.generate(data));
    }

    public boolean verifyCode(String secret, String code) {
        return codeVerifier.isValidCode(secret, code);
    }
}

Flujo MFA completo

  1. El usuario hace login con usuario y contraseña.
  2. Si las credenciales son correctas y el usuario tiene MFA activado, devuelves un token temporal de corta duración (no el JWT definitivo).
  3. El cliente solicita el código TOTP al usuario y lo envía a POST /auth/mfa/verify.
  4. Si el código es válido, emites el JWT de acceso y el refresh token definitivos.

Guarda el secreto TOTP del usuario cifrado en base de datos. No lo guardes en texto plano.

Buenas prácticas de seguridad

  • HTTPS siempre: sin TLS, todo lo anterior es inútil. Un JWT en texto plano sobre HTTP es trivial de interceptar.
  • No guardes datos sensibles en el payload del JWT: el payload no va cifrado, solo codificado en Base64. Cualquiera que tenga el token puede leer su contenido. Guarda solo el identificador del usuario y los roles.
  • Rate limiting en el endpoint de login: limita los intentos por IP con un filtro o con herramientas como Bucket4j. Sin esto, el endpoint de login es una puerta abierta a ataques de fuerza bruta.
  • Expiración corta para el JWT: 15 minutos es agresivo pero seguro. Si necesitas más comodidad para el usuario, sube a 1 hora, pero no más.
  • Revocación de tokens: JWT es stateless por diseño, lo que complica la revocación. Si necesitas poder invalidar tokens individuales (por logout o compromiso de cuenta), mantén una lista negra en Redis o en base de datos y consúltala en el filtro.

Esta arquitectura se apoya en los mismos principios que los patrones de diseño en Java como el patrón Filter Chain, donde cada filtro tiene una responsabilidad única y se encadenan en orden definido. Entender ese patrón te ayuda a razonar sobre el orden de los filtros en Spring Security.

Si estás arrancando un proyecto nuevo, considera Java 25 LTS como base. Los hilos virtuales reducen el coste de mantener peticiones autenticadas en vuelo y simplifican la gestión de concurrencia en el filtro JWT.

Con estos bloques tienes una autenticación que cubre la mayoría de escenarios de producción sin depender de librerías externas complejas ni de soluciones propietarias. Añade o quita módulos según lo que tu aplicación necesite.

Imagen: Pexels / Laura Gigch

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP