Meta description: I’ll show you how to secure a REST API with JWT in Spring Boot 3 using Spring Security 6 — from token generation to role-based access, with real gotchas I hit in production.
Last updated: June 2026
The first time I added “JWT authentication” to a Spring Boot project, I copy-pasted a StackOverflow answer, shipped it, and later discovered I had left the token secret hardcoded as "secret" in application.properties. Not great. When I migrated that same service to Spring Boot 3, I realized the old tutorials were completely broken — WebSecurityConfigurerAdapter is gone, the filter chain API changed, and the JWT libraries had breaking updates. I had to figure it all out from scratch. This guide is what I wish I had found.
TL;DR
- Spring Boot 3 dropped
WebSecurityConfigurerAdapter— you now configure security via aSecurityFilterChainbean. - Use the
jjwtlibrary (0.12.x) for JWT generation and validation; the API changed significantly from 0.9.x. - Always store the JWT secret in an environment variable, not in your config file.
Why JWT Authentication Still Matters in 2026
JSON Web Tokens (JWT) remain the most practical stateless authentication mechanism for REST APIs. Unlike session-based auth, JWTs don’t require server-side storage, which makes them a natural fit for horizontally-scaled microservices.
That said, they’re not without trade-offs. The biggest limitation I’ve hit in production: you can’t invalidate a JWT before it expires without building a token blacklist (which reintroduces state). For most internal APIs, short expiration windows (15–60 minutes) plus refresh tokens solve this. For public-facing APIs with strict security requirements, consider OAuth 2.0 instead.
[INTERNAL LINK: related article on OAuth 2.0 with Spring Boot]
[SOURCE: https://datatracker.ietf.org/doc/html/rfc7519]
Prerequisites
Before you start, make sure you have:
- Java 21 (Spring Boot 3.x requires Java 17+, but 21 is the current LTS)
- Spring Boot 3.3+
- Maven or Gradle
- Basic familiarity with Spring Security concepts
Step-by-Step: JWT Security in Spring Boot 3
Step 1: Add Dependencies
Add the following to your pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</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>
Important: If you’re upgrading from jjwt 0.9.x, the entire fluent API changed in 0.12.x.
Jwts.parser()no longer accepts asetSigningKey()call — it’s nowJwts.parser().verifyWith(key).build(). I spent two hours on this.
Step 2: Configure the JWT Secret
Set the secret in your environment, not in code:
export JWT_SECRET=your-256-bit-base64-encoded-secret-here
export JWT_EXPIRATION=86400000
Read it in application.yml:
app:
jwt:
secret: ${JWT_SECRET}
expiration: ${JWT_EXPIRATION:86400000}
Step 3: Build the JWT Service
@Service
public class JwtService {
@Value("${app.jwt.secret}")
private String secretKey;
@Value("${app.jwt.expiration}")
private long jwtExpiration;
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + jwtExpiration))
.signWith(getSigningKey())
.compact();
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractClaim(token, Claims::getExpiration).before(new Date());
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
return claimsResolver.apply(claims);
}
}
Step 4: Create the JWT Authentication Filter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String jwt = authHeader.substring(7);
final String username = jwtService.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
Step 5: Configure the Security Filter Chain
This is where Spring Boot 3 breaks from earlier versions. No more WebSecurityConfigurerAdapter:
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authenticationProvider(authenticationProvider)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Step 6: Wire Up the Authentication Provider
@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {
private final UserRepository userRepository;
@Bean
public UserDetailsService userDetailsService() {
return username -> userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService());
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Real-World Tips I Use in Production
Use role-based claims in the token. Instead of querying the database on every request to check roles, embed them as JWT claims. This keeps your filter stateless and reduces DB load.
Set a short expiration time. In production, I use 15 minutes for access tokens and 7 days for refresh tokens. This limits blast radius if a token is leaked.
Always validate the algorithm. A classic JWT attack is the “none” algorithm vulnerability. The jjwt library handles this correctly by default, but if you’re using a different library, explicitly reject tokens with alg: none.
Security Note: Never log full JWT tokens. Even in debug logs, a captured token is a valid credential until it expires. Log only the username extracted from a verified token.
[SOURCE: https://owasp.org/www-project-web-security-testing-guide/]
Common Errors and How I Fixed Them
Error: io.jsonwebtoken.security.WeakKeyException This happens when your secret key is too short for the HS256 algorithm. The minimum is 256 bits (32 bytes). I fixed it by generating a proper key:
openssl rand -base64 32
Error: 403 Forbidden on all endpoints after config I hit this because I forgot to add .requestMatchers("/api/v1/auth/**").permitAll() before calling .anyRequest().authenticated(). Spring Security denies everything by default — always whitelist your public routes explicitly.
Error: ExpiredJwtException breaking the filter chain If an expired token hits your filter, jjwt throws a JwtException. Wrap the parsing logic in a try-catch and return a clean 401, otherwise Spring returns a confusing 500:
try {
username = jwtService.extractUsername(jwt);
} catch (JwtException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
FAQ
Q: How do I add custom claims to a JWT in Spring Boot 3? A: Use the .claim(key, value) method on the JwtBuilder before calling .compact(). For example, .claim("role", userDetails.getRole()) adds a role claim you can later extract with extractClaim(token, claims -> claims.get("role", String.class)).
Q: What is the difference between JWT authentication and session-based authentication in Spring Boot? A: Session-based auth stores the session on the server and uses a cookie to identify it — this requires sticky sessions or a shared session store in clustered environments. JWT auth is stateless: all authentication data lives in the token itself, making it simpler to scale horizontally.
Q: How do I implement JWT refresh tokens in Spring Boot 3? A: Create a separate /auth/refresh endpoint that accepts the long-lived refresh token, validates it against a stored record in the database, and issues a new short-lived access token. Store refresh tokens in a database table with a revoked flag so you can invalidate them on logout.
Q: Is it safe to store JWT in localStorage vs HttpOnly cookies? A: HttpOnly cookies are more secure because they’re not accessible to JavaScript, which prevents XSS-based token theft. However, they require CSRF protection. In my APIs I use HttpOnly cookies for web clients and Authorization headers for mobile/native clients.
Q: How do I secure specific endpoints by role using JWT in Spring Boot 3? A: Use .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") in your SecurityFilterChain, and make sure the roles embedded in the JWT are loaded into the UserDetails granted authorities. The ROLE_ prefix is automatically applied by Spring Security when using hasRole().
Conclusion
Securing a REST API with JWT in Spring Boot 3 is straightforward once you understand the new SecurityFilterChain API and the updated jjwt 0.12.x syntax. The biggest gotcha is the migration from the old WebSecurityConfigurerAdapter — everything else follows logically from there. Start with short-lived tokens, keep your secret out of version control, and always handle JwtException gracefully in your filter.
About the Author: I’m a backend engineer with 8+ years building production systems on the JVM, primarily with Spring Boot, Kotlin, and PostgreSQL. I’ve secured everything from internal microservices to high-traffic public APIs, and I write about what actually works — not just what the docs say should work. When I’m not coding, I’m on HackTheBox trying to break the same kinds of systems I build during the day.
© SpiritCode — spiritcode.blog

