JWT Authentication
This page builds a complete custom JWT authentication flow on Spring Security 6 and Spring Boot 3.5. A /auth/login endpoint validates credentials and returns a signed token; a JwtAuthenticationFilter reads the Authorization: Bearer header on every subsequent request, verifies the token, and populates the SecurityContext. It reuses the building blocks from Security Config, UserDetailsService, and Password Encoding, so review the JWT basics first if tokens are new to you.
Dependencies
Add Spring Security and the jjwt (io.jsonwebtoken) library. jjwt ships as three artifacts — API, implementation, and a Jackson serializer:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<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>
Configuration properties
Keep the secret and expiry out of code:
app:
jwt:
secret: "change-me-to-a-32-byte-or-longer-base64-secret-value!!"
expiration-ms: 900000 # 15 minutes
Warning: Never commit a real secret. Inject it from an environment variable or a vault in production (
secret: ${JWT_SECRET}). The HS256 key must be at least 256 bits.
The JwtService
This service signs tokens and extracts/validates claims using jjwt 0.12.x. The API changed in 0.12 — Jwts.builder().claims(), Jwts.parser().verifyWith(key), and parseSignedClaims(...) are the current methods.
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.Map;
import java.util.function.Function;
@Service
public class JwtService {
private final SecretKey key;
private final long expirationMs;
public JwtService(@Value("${app.jwt.secret}") String secret,
@Value("${app.jwt.expiration-ms}") long expirationMs) {
this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
this.expirationMs = expirationMs;
}
public String generateToken(UserDetails user) {
Date now = new Date();
return Jwts.builder()
.subject(user.getUsername())
.claim("roles", user.getAuthorities().stream()
.map(a -> a.getAuthority()).toList())
.issuedAt(now)
.expiration(new Date(now.getTime() + expirationMs))
.signWith(key)
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> resolver) {
return resolver.apply(parseClaims(token));
}
public boolean isValid(String token, UserDetails user) {
Claims claims = parseClaims(token);
return claims.getSubject().equals(user.getUsername())
&& claims.getExpiration().after(new Date());
}
private Claims parseClaims(String token) {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
}
}
If the signature is wrong or the token is malformed/expired, jjwt throws a JwtException from parseSignedClaims — handle it in the filter.
The JwtAuthenticationFilter
A filter that runs once per request (OncePerRequestFilter). It pulls the bearer token, validates it, loads the user, and sets the authentication on the SecurityContext. Crucially, it never blocks the request itself — it simply authenticates if it can and lets the filter chain enforce authorization.
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = header.substring(7);
try {
String username = jwtService.extractUsername(token);
if (username != null
&& SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails user = userDetailsService.loadUserByUsername(username);
if (jwtService.isValid(token, user)) {
var auth = new UsernamePasswordAuthenticationToken(
user, null, user.getAuthorities());
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
} catch (JwtException ex) {
// Invalid/expired token: leave the context unauthenticated.
SecurityContextHolder.clearContext();
}
filterChain.doFilter(request, response);
}
}
The SecurityFilterChain
With JWTs the server is stateless, so disable sessions and CSRF (CSRF protection guards browser cookie sessions, which we no longer use). Register the JWT filter before the UsernamePasswordAuthenticationFilter.
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Note:
SessionCreationPolicy.STATELESStells Spring Security never to create or use anHttpSession, so theSecurityContextset by the filter lives only for that one request — exactly what stateless JWT auth needs.
The /auth/login endpoint
The controller authenticates the credentials through the AuthenticationManager (which uses your UserDetailsService and password encoder), then issues a token. DTOs are records.
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
public record LoginRequest(String username, String password) {}
public record TokenResponse(String accessToken, String tokenType) {}
@PostMapping("/login")
public TokenResponse login(@RequestBody LoginRequest req) {
var authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(req.username(), req.password()));
UserDetails user = (UserDetails) authentication.getPrincipal();
String token = jwtService.generateToken(user);
return new TokenResponse(token, "Bearer");
}
}
A bad password makes authenticate(...) throw BadCredentialsException, which Spring Security maps to 401 Unauthorized by default.
Trying it out
# 1. Log in
curl -s -X POST http://localhost:8080/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"secret"}'
Output:
{ "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZS...", "tokenType": "Bearer" }
# 2. Call a protected endpoint with the token
curl -s http://localhost:8080/api/profile \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZS..."
Output:
{ "username": "alice", "roles": ["ROLE_USER"] }
Without a valid header the same call returns 401:
HTTP/1.1 401 Unauthorized
Common pitfalls
- Forgetting
permitAll()on/auth/**— login itself becomes protected, so no one can ever get a token. - Leaving CSRF enabled on a stateless API breaks
POST/PUTfrom non-browser clients. - Filter ordering — register the JWT filter before
UsernamePasswordAuthenticationFilter, or the context is never populated in time. - Catch jjwt exceptions in the filter; an uncaught
ExpiredJwtExceptionproduces a confusing 500 instead of a clean 401. - Weak secret —
Keys.hmacShaKeyForthrows if the secret is shorter than 256 bits. Use a real, long, random value.
Tip: For a richer 401 response, register an
AuthenticationEntryPointthat writes a JSON error body viahttp.exceptionHandling(...), instead of the default empty 401.