JWT Authentication with Spring Security: Implementing Stateless Authentication for REST APIs
When building a REST API, you’ll inevitably run into situations where you want to complete authentication on a per-request basis without using sessions. For those who have gotten comfortable with Spring Security through form authentication or Basic authentication, the next step is stateless authentication using JWT (JSON Web Token).
In How to Implement Basic Authentication and Form Authentication with Spring Security, we covered the fundamentals of authentication. As a continuation, this article implements JWT issuance, validation, and integration into a Filter from scratch for REST APIs.
By the end, you’ll be able to do the following on your own:
- POST to
/api/auth/loginto receive a JWT - Attach the obtained token to the Authorization header to access protected endpoints
- Verify end-to-end behavior with curl / Postman
Here’s the overall implementation flow:
Add dependencies → JwtUtil → Login endpoint → JwtAuthenticationFilter → SecurityFilterChain configuration
Let’s go through it step by step.
Understanding JWT and Stateless Authentication in 3 Minutes
The 3-Part Structure of a JWT
A JWT (JSON Web Token) consists of three parts separated by dots (.).
header.payload.signature
| Part | Content | Encoding |
|---|---|---|
| Header | Algorithm type (e.g., HS256) | Base64URL |
| Payload | Claims (username, expiration, etc.) | Base64URL |
| Signature | Header and Payload signed together with a secret key | Base64URL of binary data |
The Header and Payload are merely Base64URL-encoded and are not encrypted. Avoid including sensitive information (such as passwords) in the Payload. The Signature is the key to detecting tampering.
The Stateless Authentication Flow
For concrete steps on actually running this horizontally scalable setup on Kubernetes, see How to Deploy a Spring Boot App to Kubernetes.
Client Server
| |
| POST /api/auth/login |
| { username, password } -------> |
| | Authentication succeeds → generate JWT
| { token: "eyJ..." } <--------- |
| |
| GET /api/protected |
| Authorization: Bearer eyJ... --> |
| | Validate JWT → set in SecurityContext
| 200 OK <--------- |
The key point is that the server does not hold any session state. Since the authentication information is contained within the token itself, it’s easy to scale horizontally across multiple server instances. This is the fundamental difference from form authentication and Basic authentication.
Out of scope for this article: OAuth2/OIDC integration, detailed refresh token implementation, and JWT propagation between microservices are not covered.
Adding Dependencies (Maven / Gradle)
This article uses the jjwt (Java JWT) library. Starting with jjwt 0.11, the artifact is split into three parts.
| Artifact | Role |
|---|---|
jjwt-api | API interface |
jjwt-impl | Implementation (needed only at runtime) |
jjwt-jackson | JSON parsing via Jackson |
Maven (pom.xml)
<dependencies>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- jjwt -->
<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>
</dependencies>
Gradle (build.gradle)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
}
Note: The older
jjwt 0.9.xseries has a significantly different API. If you come across articles online using syntax likeJwts.parser().setSigningKey(...), that’s 0.9.x-style code. This article uses the newer API from 0.11 onward. Check Maven Central for the latest version.
application.properties: Externalizing the JWT Secret Key and Expiration
If you want to manage the secret key encrypted rather than keeping it in plain text in application.properties, see How to Encrypt Sensitive Configuration Values in Spring Boot Using Jasypt as well.
First, externalize the configuration values. The secret key should be managed via environment variables or Vault in production rather than written directly into application.properties (more on this below).
# JWT configuration
# Set jwt.secret to a random string of at least 32 characters (256 bits)
# Example generation: openssl rand -hex 32
jwt.secret=please-change-this-secret-key-in-production-environment-32chars
jwt.expiration=86400000
# 86400000ms = 24 hours
Watch the unit:
jwt.expirationis specified in milliseconds. Mistaking it for seconds will issue tokens that expire 1000 times faster (a common pitfall).
JwtUtil: Token Generation, Claim Extraction, and Expiration Validation
Let’s implement a utility class that consolidates all JWT operations in one place.
package com.example.demo.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.function.Function;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
// Generate the signing key (convert the application.properties value to UTF-8 bytes to build an HMAC-SHA256 key)
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
// Generate a token
public String generateToken(UserDetails userDetails) {
return Jwts.builder()
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSigningKey())
.compact();
}
// Extract the username
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// Extract the expiration
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
// Generic claim extraction
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
// Retrieve the Claims while performing signature verification. Invalid tokens throw an exception here
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
// Check expiration
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
// Validate the token (signature verification + expiration check + username match check)
// Note: ExpiredJwtException etc. may be thrown inside extractAllClaims.
// Catch these in the calling JwtAuthenticationFilter.
public boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
}
Exception-handling policy:
| Exception | Meaning | How to handle |
|---|---|---|
ExpiredJwtException | Expired | Return 401 |
MalformedJwtException | Malformed token | Return 401 |
SignatureException | Invalid signature (tampered) | Return 401 |
UnsupportedJwtException | Unsupported JWT format | Return 401 |
These exceptions are caught in the JwtAuthenticationFilter described later.
Implementing UserDetailsService
Let’s set up a Service that Spring Security uses to load users.
Note: You should define only one of the following two implementations in your project — the in-memory implementation (
UserDetailsConfig) or the DB-backed implementation (UserDetailsServiceImpl). Defining both will cause a conflict in theUserDetailsServicebean definition, and the application will fail to start.Steps to migrate from in-memory to DB-backed:
- Remove the
@Configurationannotation from theUserDetailsConfigclass, or delete the class entirely- Enable the
@Serviceannotation onUserDetailsServiceImpl(already applied in the code below)- Move the
PasswordEncoder@Beandefinition toSecurityConfigor similar
In-Memory Implementation (Sample / Verification Only)
package com.example.demo.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class UserDetailsConfig {
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
return new InMemoryUserDetailsManager(
User.builder()
.username("user")
.password(passwordEncoder.encode("password"))
.roles("USER")
.build()
);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Extending to DB Integration (Production Pattern)
In production, users are loaded from a database. The basic pattern for implementing the interface is as follows.
package com.example.demo.security;
import com.example.demo.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUsername(username)
.map(user -> User.builder()
.username(user.getUsername())
.password(user.getPassword()) // Already BCrypt-encoded value
.roles(user.getRole())
.build()
)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}
getPassword() must return a BCrypt-encoded hash value. Be careful — if you return the plain-text password as-is, authentication will always fail.
LoginRequest / AuthenticationResponse DTO Classes
package com.example.demo.auth;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank String username,
@NotBlank String password
) {}
package com.example.demo.auth;
public record AuthenticationResponse(String token) {}
For details on validation annotations, see How to Use @Valid for Validation in Spring Boot.
Implementing the /api/auth/login Endpoint
For a production-grade error-handling design that also covers exceptions beyond authentication errors, see Implementing Spring Boot’s GlobalExceptionHandler for Production Use in detail.
package com.example.demo.auth;
import com.example.demo.security.JwtUtil;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.*;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationManager authenticationManager;
private final UserDetailsService userDetailsService;
private final JwtUtil jwtUtil;
@PostMapping("/login")
public ResponseEntity<AuthenticationResponse> login(@Valid @RequestBody LoginRequest request) {
try {
// Delegate authentication to Spring Security (throws BadCredentialsException on failure)
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password())
);
} catch (BadCredentialsException e) {
return ResponseEntity.status(401).build();
}
// Authentication succeeded → generate and return a JWT
UserDetails userDetails = userDetailsService.loadUserByUsername(request.username());
String token = jwtUtil.generateToken(userDetails);
return ResponseEntity.ok(new AuthenticationResponse(token));
}
}
Key point: AuthenticationManager.authenticate() invokes the entire Spring Security authentication mechanism. It automatically handles BCrypt password verification and calls into UserDetailsService, so you don’t need to write your own comparison logic.
For error-handling design, also see How to Unify Exception Handling in a Spring Boot REST API.
Implementing JwtAuthenticationFilter (OncePerRequestFilter)
Let’s implement a Filter that runs exactly once for every request. This is the core of JWT authentication.
Note: Do not annotate this class with
@Component. Doing so would cause Spring Boot to automatically register it as a servlet filter, resulting in double registration alongside theaddFilterBeforeregistration on theSecurityFilterChain. By defining it as a@BeanwithinSecurityConfig, it functions correctly as a filter dedicated solely to the security chain.
package com.example.demo.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
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.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
// Skip if there is no Authorization header, or it isn't a Bearer token
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String token = authHeader.substring(7); // Strip the 7 characters of "Bearer "
final String username;
try {
username = jwtUtil.extractUsername(token);
} catch (Exception e) {
// Malformed token, signature error, etc. → return 401 and halt the FilterChain
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT token");
return;
}
// Only proceed if the username was successfully obtained and authentication hasn't already been set
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(token, userDetails)) {
// Build the authentication object and set it in the SecurityContext
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authToken);
} else {
// Validation failed, e.g., expired token
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT token expired or invalid");
return;
}
}
filterChain.doFilter(request, response);
}
}
Why use OncePerRequestFilter: A servlet Filter can sometimes be invoked multiple times for a single request, such as during a request forward. OncePerRequestFilter guarantees that it runs exactly once per request.
Configuring the SecurityFilterChain
package com.example.demo.security;
import jakarta.servlet.http.HttpServletResponse;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsService userDetailsService;
// Define JwtAuthenticationFilter as a Bean (do not add @Component)
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtUtil, userDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// No CSRF token needed for a stateless API (since sessions aren't used)
.csrf(csrf -> csrf.disable())
// Never create or use a session
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// Return 401 for unauthenticated requests
.exceptionHandling(e -> e.authenticationEntryPoint(
(req, res, ex) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")
))
// Access control per endpoint
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // No authentication required for login
.anyRequest().authenticated() // Authentication required for everything else
)
// Insert JwtAuthenticationFilter before UsernamePasswordAuthenticationFilter
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
// Expose AuthenticationManager as a Bean (used in AuthenticationController)
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config
) throws Exception {
return config.getAuthenticationManager();
}
}
On the placement of addFilterBefore: By inserting it before UsernamePasswordAuthenticationFilter, JWT validation runs before Spring Security attempts form authentication. If the JWT is valid, the authentication information is set in the SecurityContext, so subsequent Filters treat the request as already authenticated.
Rationale for csrf().disable(): CSRF is primarily an attack that exploits browser session cookies. With stateless JWT authentication, the token is sent via the Authorization header rather than a cookie, so CSRF attacks don’t apply. That said, HTTPS is still mandatory (discussed below).
Verifying Behavior: End-to-End Testing with curl / Postman
Once the app is running, verify its behavior with the following steps.
1. Log in and obtain a JWT
curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username": "user", "password": "password"}'
Example response:
{
"token": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwiaWF0IjoxNzA2MDAwMDAwLCJleHAiOjE3MDYwODY0MDB9.xxxxx"
}
2. Access a protected endpoint with the obtained token
# Set the TOKEN variable to the token from the response
TOKEN="eyJhbGciOiJIUzI1NiJ9..."
curl -s http://localhost:8080/api/hello \
-H "Authorization: Bearer $TOKEN"
3. Verifying Error Cases
Spring Security returns 401 Unauthorized for unauthenticated requests. Since this is explicitly configured via the authenticationEntryPoint in exceptionHandling, 401 is returned in all of the following cases.
# No token → 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello
# Example output: 401
# Tampered token → 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello \
-H "Authorization: Bearer invalidtoken"
# Example output: 401
# Expired token → 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello \
-H "Authorization: Bearer <expired-token>"
# Example output: 401
Efficient Testing with Postman
Define a token variable in Postman’s Environment Variables, and set the following in the Tests tab of the login request. This automatically attaches the token to subsequent requests.
const response = pm.response.json();
pm.environment.set("token", response.token);
Then simply specify Bearer {{token}} in the Authorization tab of subsequent requests.
Summary
This article covered the steps to integrate JWT authentication into a Spring Boot REST API.
| Step | Implementation |
|---|---|
| 1 | Add the jjwt dependencies |
| 2 | Externalize the secret key and expiration in application.properties |
| 3 | Implement token generation and validation logic in JwtUtil |
| 4 | Set up the foundation for user loading with UserDetailsService |
| 5 | Implement the JWT issuance endpoint at /api/auth/login |
| 6 | Implement per-request token validation with JwtAuthenticationFilter |
| 7 | Configure STATELESS settings, filter insertion, and authorization rules via SecurityFilterChain |
| 8 | Run end-to-end tests with curl / Postman |
Stepping up from Basic authentication and form authentication to JWT authentication is a natural evolution in REST API design. Give it a try in your own project.
Differences Between Spring Security OAuth2 Resource Server and JWT Authentication
What this article implemented is a scheme where your own application issues and validates the JWT itself. On the other hand, if you only need to validate JWTs issued by an authorization server (such as Keycloak), there’s also the option of using Spring Security’s oauth2-resource-server starter.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
With this starter, you don’t need to build a custom JwtAuthenticationFilter — simply configuring .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) automates signature verification via the JWK endpoint. The criteria for choosing between them are simple:
- If your own application is the token issuer, use the custom implementation described in this article
- If you only need to validate tokens issued by an external authorization server, use
oauth2-resource-server
Both share the same underlying foundation of “stateless authentication,” so choose whichever fits your project’s architecture.