programemos/artículo
Spring BootKotlinJWTSpring Security

Autenticación JWT en Spring Boot 3 paso a paso

Cómo implementar autenticación stateless con JWT en Spring Boot 3 usando Spring Security 6, desde cero y sin librerías externas innecesarias.

·3 min read

JSON Web Tokens (JWT) son el estándar para autenticación stateless en APIs REST. En este tutorial implementamos el flujo completo en Spring Boot 3 con Kotlin.

¿Qué vamos a construir?

Un endpoint /auth/login que devuelve un JWT, y un filtro que valida ese token en cada request. Sin bases de datos externas para las sesiones, sin estado en el servidor.

Dependencias

En build.gradle.kts:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("io.jsonwebtoken:jjwt-api:0.12.3")
    runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3")
    runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3")
}

Generación del token

@Service
class JwtService(
    @Value("\${jwt.secret}") private val secret: String,
    @Value("\${jwt.expiration-ms:86400000}") private val expirationMs: Long,
) {
    private val key: SecretKey by lazy {
        Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret))
    }

    fun generate(username: String): String =
        Jwts.builder()
            .subject(username)
            .issuedAt(Date())
            .expiration(Date(System.currentTimeMillis() + expirationMs))
            .signWith(key)
            .compact()

    fun extractUsername(token: String): String =
        Jwts.parser().verifyWith(key).build()
            .parseSignedClaims(token).payload.subject
}

Filtro de autenticación

El filtro intercepta cada request, extrae el token del header Authorization: Bearer <token> y lo valida.

@Component
class JwtAuthFilter(
    private val jwtService: JwtService,
    private val userDetailsService: UserDetailsService,
) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        chain: FilterChain,
    ) {
        val header = request.getHeader("Authorization")
        if (header == null || !header.startsWith("Bearer ")) {
            chain.doFilter(request, response)
            return
        }

        val token = header.removePrefix("Bearer ")
        val username = runCatching { jwtService.extractUsername(token) }.getOrNull()

        if (username != null && SecurityContextHolder.getContext().authentication == null) {
            val userDetails = userDetailsService.loadUserByUsername(username)
            val auth = UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.authorities
            )
            auth.details = WebAuthenticationDetailsSource().buildDetails(request)
            SecurityContextHolder.getContext().authentication = auth
        }

        chain.doFilter(request, response)
    }
}

Configuración de Spring Security

@Configuration
@EnableWebSecurity
class SecurityConfig(private val jwtAuthFilter: JwtAuthFilter) {

    @Bean
    fun securityFilterChain(http: HttpSecurity): SecurityFilterChain =
        http
            .csrf { it.disable() }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .authorizeHttpRequests {
                it.requestMatchers("/auth/**").permitAll()
                    .anyRequest().authenticated()
            }
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter::class.java)
            .build()
}

Endpoint de login

@RestController
@RequestMapping("/auth")
class AuthController(
    private val authManager: AuthenticationManager,
    private val jwtService: JwtService,
) {
    @PostMapping("/login")
    fun login(@RequestBody body: LoginRequest): ResponseEntity<Map<String, String>> {
        authManager.authenticate(
            UsernamePasswordAuthenticationToken(body.username, body.password)
        )
        val token = jwtService.generate(body.username)
        return ResponseEntity.ok(mapOf("token" to token))
    }
}

data class LoginRequest(val username: String, val password: String)

Variables de entorno

En application.yml:

jwt:
  secret: ${JWT_SECRET}
  expiration-ms: 86400000

Generá el secret con openssl rand -base64 64 y ponelo en tu .env o en las variables de entorno del servidor.

Probarlo

# Login
curl -X POST http://localhost:8080/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"password"}'

# Request autenticado
curl http://localhost:8080/api/datos \
  -H "Authorization: Bearer <token>"

Con esto tenés autenticación JWT completamente funcional y stateless. El siguiente paso es agregar refresh tokens para no obligar al usuario a loguearse cada 24 horas.