In the landscape of modern Java development, securing REST APIs is not just a feature—it is the foundation of trust. As we move through 2025, the standard for microservices and single-page application (SPA) backends remains firmly rooted in Stateless Authentication.
While Spring Security has historically been viewed as complex or “heavy,” the evolution of Spring Boot 3 and Spring Security 6 has dramatically streamlined configuration. The days of extending WebSecurityConfigurerAdapter are long gone. Today, we rely on component-based security configurations and the functional Lambda DSL to create readable, robust security chains.
In this guide, we will build a production-grade authentication system using Spring Security 6 and JSON Web Tokens (JWT). We will cover the architecture, the code implementation, and the crucial “gotchas” that separate a tutorial project from a production-ready system.
1. Prerequisites and Environment #
To follow this tutorial effectively, ensure your development environment meets the 2025 standards for Java development:
- JDK 21 LTS or higher.
- Spring Boot 3.3+ (or the latest stable 3.x release).
- Maven or Gradle.
- An IDE like IntelliJ IDEA or VS Code.
- Basic understanding of Dependency Injection and REST principles.
2. Architecture: Stateless vs. Stateful #
Before writing code, it is vital to understand why we are using JWT. In traditional web development, server-side sessions were king. However, in distributed systems and cloud-native environments, storing session state on the server creates a bottleneck.
The Comparison #
Here is how modern Token-Based authentication compares to traditional Session-Based authentication:
| Feature | Session-Based (Stateful) | Token-Based (Stateless / JWT) |
|---|---|---|
| State Storage | Server memory or Redis/Database | Client side (Browser/Mobile App) |
| Scalability | Harder; requires sticky sessions or session replication | Excellent; servers need no knowledge of other requests |
| Cross-Domain | Difficult (CORS, Cookies) | Easier; Header-based |
| Performance | Database lookup required for every request | CPU-intensive (crypto signature check), but no DB lookup needed |
| Revocation | Easy (delete session) | Harder (requires blacklisting or short expiry) |
The Authentication Flow #
We will implement a flow where the user exchanges credentials for a signed JWT. Subsequent requests use this token to gain access to protected resources.
3. Step-by-Step Implementation #
Let’s build this application from the ground up.
Step 3.1: Dependencies #
We need the Spring Security starter and a library to handle JWT manipulation. In 2025, jjwt (Java JWT) remains a reliable choice.
Add the following to your pom.xml:
<dependencies>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Web Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Lombok (Optional but recommended) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JWT Library -->
<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>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
</dependencies>Step 3.2: The User Model #
Spring Security works with the UserDetails interface. You can implement this directly on your JPA Entity, but for separation of concerns, it is often better to map your entity to a specific Security User class. For simplicity, we will implement it directly here.
package com.javadevpro.security.user;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
public class User implements UserDetails {
private String username;
private String password;
private Role role; // Enum: USER, ADMIN
// Constructor, Getters, Setters omitted for brevity...
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(role.name()));
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return username;
}
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true; }
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return true; }
}Step 3.3: The JWT Service #
This service is the cryptographic heart of our application. It handles signing (creating tokens) and verifying (reading tokens).
Best Practice: Never hardcode your secret key in Java classes. Use application.properties or environment variables.
package com.javadevpro.security.config;
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.HashMap;
import java.util.Map;
import java.util.function.Function;
@Service
public class JwtService {
@Value("${application.security.jwt.secret-key}")
private String secretKey; // 256-bit hex-encoded key
@Value("${application.security.jwt.expiration}")
private long jwtExpiration;
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public String generateToken(UserDetails userDetails) {
return generateToken(new HashMap<>(), userDetails);
}
public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
return Jwts.builder()
.claims(extraClaims)
.subject(userDetails.getUsername())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + jwtExpiration))
.signWith(getSignInKey())
.compact();
}
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 extractExpiration(token).before(new Date());
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private Claims extractAllClaims(String token) {
return Jwts.parser()
.verifyWith(getSignInKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
private SecretKey getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}Step 3.4: The JWT Authentication Filter #
This is a custom filter that executes once per request. It checks for the Authorization header, extracts the JWT, validates it, and if valid, sets the authentication in the SecurityContextHolder.
Crucially, if the context is already set, we skip this to avoid overhead.
package com.javadevpro.security.config;
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 {
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String userEmail;
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7);
userEmail = jwtService.extractUsername(jwt);
if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
// Update Security Context
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}Step 3.5: Security Configuration (The Glue) #
This is where the magic happens. In Spring Security 6, we define a SecurityFilterChain bean. Note the use of SessionCreationPolicy.STATELESS—this tells Spring, “Do not create a JSESSIONID; treat every request as new.”
package com.javadevpro.security.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(req -> req
.requestMatchers("/api/v1/auth/**").permitAll() // Whitelist login/register
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authenticationProvider(authenticationProvider)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}Note: The AuthenticationProvider bean (usually DaoAuthenticationProvider) needs to be defined in a separate ApplicationConfig class, linking your UserDetailsService and PasswordEncoder.
4. Implementation Details & Best Practices #
Writing the code is only half the battle. To ensure your application is secure and performant in a production environment, consider these points.
Password Encoding #
Never store passwords in plain text. Use BCryptPasswordEncoder. While Pbkdf2PasswordEncoder is a strong alternative, BCrypt remains the industry standard for general-purpose web applications in 2025 due to its balance of security and performance cost.
Handling Exceptions #
By default, if a JWT is invalid, Spring might return a generic 403 Forbidden. To provide better API error messages (e.g., “Token Expired” vs. “Invalid Signature”), you should implement a AuthenticationEntryPoint and register it in your SecurityFilterChain via .exceptionHandling().
Key Rotation #
In the JwtService, we used a single secret key. In high-security banking or healthcare apps, you should support Key Rotation. This involves signing tokens with a Private Key and verifying them with a Public Key (Asymmetric Encryption, usually RSA or ECDSA), allowing you to rotate keys without invalidating all existing tokens immediately if you maintain a set of valid public keys.
Refresh Tokens #
JWTs should be short-lived (e.g., 15 minutes). To prevent the user from logging in constantly, implement a Refresh Token mechanism.
- Access Token: Short life, carries permissions, used for API calls.
- Refresh Token: Long life (7-30 days), stored securely (HttpOnly Cookie), used solely to request a new Access Token.
5. Testing the Implementation #
To verify your setup, create a simple controller:
@RestController
@RequestMapping("/api/v1/demo")
public class DemoController {
@GetMapping
public ResponseEntity<String> sayHello() {
return ResponseEntity.ok("Hello from a secured endpoint!");
}
}- Attempt 1: Send a GET request without a header. Result:
403 Forbidden. - Attempt 2: Register/Login to get a token.
- Attempt 3: Send a GET request with header
Authorization: Bearer <your-token>. Result:200 OK.
6. Conclusion #
Moving to Spring Security 6 and Spring Boot 3 has simplified the boilerplate required to secure Java applications. By combining the flexibility of the SecurityFilterChain with the stateless nature of JWTs, you create a scalable architecture ready for microservices and cloud deployment.
Key Takeaways:
- Use Stateless session policy for REST APIs.
- Implement a OncePerRequestFilter to intercept and validate JWTs.
- Keep your Secret Keys out of source code.
- Always use HTTPS in production to protect the token during transit.
For further reading, explore the Official Spring Security Documentation or dive into OAuth2 resource servers if you are delegating authentication to providers like Auth0 or Keycloak.
Did you find this guide helpful? Subscribe to the Java DevPro newsletter for more deep dives into Spring Boot architecture and performance tuning.