When developing REST APIs, there will always come a point where you want to complete authentication per request without using sessions. For those who have gotten comfortable with Spring Security through form-based or Basic authentication, the natural next step is stateless authentication using JWT (JSON Web Token).
How to Implement Basic Authentication and Form Authentication with Spring Security covered the fundamentals of authentication. This article builds on that by implementing JWT issuance, validation, and Filter integration for REST APIs from scratch.
By the end, you will be able to:
- 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
The overall implementation flow is as follows:
Add dependencies → JwtUtil → Login endpoint → JwtAuthenticationFilter → SecurityFilterChain configuration
Let’s go through each step.
Understanding JWT and Stateless Authentication in 3 Minutes
The 3-Part Structure of 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 | Binary Base64URL |
The Header and Payload are only Base64URL-encoded — they are not encrypted. Avoid including sensitive information (such as passwords) in the Payload. The Signature is the key to tamper detection.
Stateless Authentication Flow
Client Server
| |
| POST /api/auth/login |
| { username, password } -------> |
| | Auth success → Generate JWT
| { token: "eyJ..." } <--------- |
| |
| GET /api/protected |
| Authorization: Bearer eyJ... --> |
| | Validate JWT → Set in SecurityContext
| 200 OK <--------- |
The key point is that the server holds no session state. Because authentication information is contained within the token itself, horizontal scaling across multiple server instances becomes straightforward. This is the fundamental difference from form-based 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. Since jjwt 0.11, the artifacts are split into three.
| Artifact | Role |
|---|---|
jjwt-api | API interfaces |
jjwt-impl | Implementation (runtime only) |
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 code online usingJwts.parser().setSigningKey(...), that is 0.9.x code. This article uses the new API from 0.11 onward. Check Maven Central for the latest version.
application.properties: Externalizing the JWT Secret and Expiration
First, externalize your configuration values. In production, the secret key should be managed via environment variables or a vault — avoid hardcoding it in application.properties (more on this later).
# JWT configuration
# Set jwt.secret to a random string of 32 characters (256 bits) or more
# Generation example: openssl rand -hex 32
jwt.secret=please-change-this-secret-key-in-production-environment-32chars
jwt.expiration=86400000
# 86400000ms = 24 hours
Watch the units:
jwt.expirationis specified in milliseconds. Confusing it with seconds will result in tokens that expire 1000 times sooner (a common pitfall).
JwtUtil: Token Generation, Claim Extraction, and Expiration Validation
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 (converts the application.properties value to an HMAC-SHA256 key as UTF-8 bytes)
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 username
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
// Extract expiration date
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) {
// Retrieves Claims while verifying the signature. Invalid tokens will 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 token (signature verification + expiration check + username match)
// Note: extractAllClaims may throw ExpiredJwtException, etc.
// Handle these with try-catch 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 | Response |
|---|---|---|
ExpiredJwtException | Token has expired | Return 401 |
MalformedJwtException | Malformed token format | Return 401 |
SignatureException | Invalid signature (tampered) | Return 401 |
UnsupportedJwtException | Unsupported JWT format | Return 401 |
These exceptions are caught in the JwtAuthenticationFilter described below.
Implementing UserDetailsService
Provide a Service for Spring Security to load users.
Note: The in-memory implementation (
UserDetailsConfig) and the DB-backed implementation (UserDetailsServiceImpl) below should only one of them be defined in your project. Defining both will cause a conflictingUserDetailsServiceBean definition and prevent the application from starting.Steps to migrate from in-memory to DB-backed:
- Remove the
@Configurationannotation fromUserDetailsConfig, or delete the class entirely- Enable the
@Serviceannotation onUserDetailsServiceImpl(already present in the code below)- Move the
PasswordEncoder@Beandefinition toSecurityConfigor equivalent
In-Memory Implementation (for samples and testing)
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-backed (Production Pattern)
In production, load users from the 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()) // Must be a BCrypt-encoded value
.roles(user.getRole())
.build()
)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
}
getPassword() must return a BCrypt-encoded hash. Returning a plaintext password will cause authentication to 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 Validate with @Valid in Spring Boot.
Implementing the /api/auth/login Endpoint
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 successful → generate and return 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 UserDetailsService calls, so you do not need to write your own comparison logic.
For error handling design, also refer to How to Centralize Exception Handling in Spring Boot REST APIs.
Implementing JwtAuthenticationFilter (OncePerRequestFilter)
Implement a Filter that executes exactly once per request. This is the core of JWT authentication.
Note: Do not annotate this class with
@Component. Adding@Componentcauses Spring Boot to auto-register it as a servlet filter, which combined with theaddFilterBeforeregistration inSecurityFilterChainresults in the filter being registered twice. Defining it as a@BeaninsideSecurityConfigensures it functions correctly as a security-chain-only filter.
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 Authorization header is absent or not a Bearer token
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
final String token = authHeader.substring(7); // Strip the 7-character "Bearer " prefix
final String username;
try {
username = jwtUtil.extractUsername(token);
} catch (Exception e) {
// Malformed token, signature error, etc. → return 401 and stop the FilterChain
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid JWT token");
return;
}
// Only proceed if a username was extracted and the request is not yet authenticated
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtil.validateToken(token, userDetails)) {
// Create an 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., token expired)
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "JWT token expired or invalid");
return;
}
}
filterChain.doFilter(request, response);
}
}
Why use OncePerRequestFilter: Servlet Filters can be invoked multiple times during request forwarding and similar operations. OncePerRequestFilter guarantees execution exactly once per request.
Configuring 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 use @Component)
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(jwtUtil, userDetailsService);
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// CSRF tokens are unnecessary for stateless APIs (no session usage)
.csrf(csrf -> csrf.disable())
// Never create or use sessions
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// Return 401 for unauthenticated requests
.exceptionHandling(e -> e.authenticationEntryPoint(
(req, res, ex) -> res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")
))
// Per-endpoint access control
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // Login requires no authentication
.anyRequest().authenticated() // All other endpoints require authentication
)
// 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 position of addFilterBefore: By inserting the filter before UsernamePasswordAuthenticationFilter, JWT validation runs before Spring Security attempts form-based authentication. If the JWT is valid, authentication information is set in the SecurityContext, and subsequent filters treat the request as authenticated.
Rationale for csrf().disable(): CSRF attacks primarily exploit browser session cookies. In stateless JWT authentication, tokens are sent via the Authorization header rather than cookies, making CSRF attacks ineffective. However, HTTPS is mandatory (see below).
Verification: End-to-End Testing with curl / Postman
Once the application is running, verify its behavior with the following steps.
1. Log in to 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 TOKEN variable to the token value from the response
TOKEN="eyJhbGciOiJIUzI1NiJ9..."
curl -s http://localhost:8080/api/hello \
-H "Authorization: Bearer $TOKEN"
3. Verify error cases
Spring Security returns 401 Unauthorized for unauthenticated requests. Because the authenticationEntryPoint is explicitly configured in exceptionHandling, all of the following cases return 401.
# No token → 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello
# Output: 401
# Tampered token → 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello \
-H "Authorization: Bearer invalidtoken"
# Output: 401
# Expired token → 401
curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello \
-H "Authorization: Bearer <expired-token>"
# Output: 401
Efficient Testing with Postman
Define a token variable in Postman’s Environment Variables and add the following to the Tests tab of your login request. The token will then be automatically attached to all subsequent requests.
const response = pm.response.json();
pm.environment.set("token", response.token);
For subsequent requests, simply specify Bearer {{token}} in the Authorization tab.
Summary
This article walked through the steps to integrate JWT authentication into a Spring Boot REST API.
| Step | Implementation |
|---|---|
| 1 | Add jjwt dependencies |
| 2 | Externalize secret key and expiration to application.properties |
| 3 | Implement token generation and validation logic in JwtUtil |
| 4 | Set up the user-loading foundation with UserDetailsService |
| 5 | Implement the JWT issuance endpoint at /api/auth/login |
| 6 | Implement per-request token validation in JwtAuthenticationFilter |
| 7 | Configure STATELESS policy, Filter insertion, and authorization rules in SecurityFilterChain |
| 8 | Run end-to-end tests with curl / Postman |
Moving from Basic or form-based authentication to JWT authentication is a natural evolution in REST API design. Give it a try in your own project.