Demystifying Spring Security 6 & JWT: Your First Steps to Secure APIs
Dive into the world of secure API development with Spring Security 6 and JSON Web Tokens (JWT). This introductory guide walks you through setting up a modern, stateless authentication system, from project setup to a working example.
Welcome to CoddyKit’s comprehensive series on mastering Spring Security 6 and JWT authentication! In today's interconnected world, securing your APIs is not just a best practice—it's a necessity. Data breaches are costly, reputation-damaging, and frankly, avoidable with the right tools and knowledge.
For modern web applications, especially those built with a decoupled frontend (like React, Angular, or Vue) and a backend API, traditional session-based authentication often falls short. This is where JSON Web Tokens (JWTs) paired with the robust capabilities of Spring Security 6 shine. They offer a stateless, scalable, and efficient way to manage authentication and authorization.
This post is the first in a five-part series designed to transform you from a security novice to a confident architect of secure Spring Boot applications. In this initial installment, we'll lay the groundwork: understanding JWTs, exploring Spring Security 6's core concepts, and building a basic, functional JWT authentication system from scratch.
What is a JSON Web Token (JWT)?
Before we dive into code, let's understand the star of our show: JWT. Pronounced 'jot', a JWT is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed.
Structure of a JWT
A JWT typically consists of three parts, separated by dots (.), which are Base64-URL encoded:
- Header: Contains the token type (JWT) and the signing algorithm (e.g., HMAC SHA256 or RSA).
- Payload: Contains the claims (statements about an entity, typically the user, and additional data). Common claims include
iss(issuer),exp(expiration time),sub(subject), and custom data specific to your application. - Signature: Created by taking the encoded header, the encoded payload, a secret, and the algorithm specified in the header, and signing it. This signature is used to verify that the sender of the JWT is who it says it is and to ensure the message hasn't been tampered with.
The beauty of JWTs lies in their stateless nature. Once a user authenticates and receives a token, that token contains all the necessary information for the server to verify their identity and permissions without needing to consult a session store or database on every request. This makes them ideal for microservices architectures and distributed systems.
Why Spring Security 6?
Spring Security is the de-facto standard for securing Spring-based applications. Version 6 brings significant improvements and simplifications, especially in its declarative security configurations and support for modern authentication patterns. Key aspects that make it powerful for JWT include:
- Simplified Configuration: A more streamlined DSL (Domain Specific Language) for configuring security filters, making it easier to define custom security chains.
- Stateless Session Management: Easily configure Spring Security to operate without sessions, which is crucial for JWT-based authentication.
- Extensibility: Highly customizable filter chain that allows injecting custom filters for JWT validation.
- Reactive Support: While we'll focus on traditional servlet-based applications here, Spring Security 6 also has excellent support for reactive applications, future-proofing your skills.
Setting Up Your Project: The Foundation
Let's get our hands dirty! We'll start by generating a new Spring Boot project. Head over to Spring Initializr and configure your project with the following dependencies:
Spring WebSpring SecurityLombok(for boilerplate reduction)JJWT(for JWT creation and parsing)
For Maven, your pom.xml dependencies section should look something like this (ensure you're using the latest stable versions):
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- JJWT for JWT handling -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<!-- Spring Boot Test for testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Core Components for JWT Authentication
To implement JWT authentication, we'll need several key components:
SecurityFilterChainConfiguration: The central place to define your security rules, disable sessions, and integrate custom filters.UserDetailsService: To load user-specific data during authentication.PasswordEncoder: To securely store and verify user passwords.JwtService: A custom service to handle JWT creation, parsing, and validation.JwtAuthenticationFilter: A custom filter to intercept incoming requests, extract and validate JWTs, and set the authenticated user in the Spring Security context.- Authentication Controller: An endpoint for users to log in and receive a JWT.
Step-by-Step Implementation
1. Security Configuration
First, let's configure Spring Security. Create a class named SecurityConfig (e.g., in a config package):
import com.example.jwt.filter.JwtAuthenticationFilter;
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.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthFilter;
private final AuthenticationProvider authenticationProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authenticationProvider(authenticationProvider)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
In this configuration:
csrf.disable(): CSRF protection is not typically needed for stateless APIs using JWTs, as tokens are sent via headers.authorizeHttpRequests(): Defines authorization rules. We permit all requests to/api/auth/**(for login/registration) and require authentication for all other requests.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS): Crucial for JWT. It tells Spring Security not to create or use HTTP sessions.authenticationProvider(): We'll define this bean shortly.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class): This injects our custom JWT filter into the Spring Security filter chain *before* the standardUsernamePasswordAuthenticationFilter.
2. User Details Service and Password Encoder
We need a way to load user details and encode passwords. For simplicity, we'll use an in-memory user for now.
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.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {
// For a real application, inject a UserRepository here
// private final UserRepository userRepository;
@Bean
public UserDetailsService userDetailsService() {
// In a real application, fetch user from a database
return username -> {
if ("user@example.com".equals(username)) {
return User.builder()
.username("user@example.com")
.password(passwordEncoder().encode("password")) // Encode the password
.roles("USER")
.build();
}
throw new UsernameNotFoundException("User not found");
};
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService());
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Here, userDetailsService() provides a mock user. authenticationProvider() uses this service and our passwordEncoder() to validate credentials. authenticationManager() is exposed as a bean, which we'll use in our login endpoint.
3. JWT Service
This service will handle generating and validating JWTs. Create a JwtService.java class:
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
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 java.security.Key;
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;
@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()
.setClaims(extraClaims)
.setSubject(userDetails.getUsername())
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + jwtExpiration))
.signWith(getSignInKey(), SignatureAlgorithm.HS256)
.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
.parserBuilder()
.setSigningKey(getSignInKey())
.build()
.parseClaimsJws(token)
.getBody();
}
private Key getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
return Keys.hmacShaKeyFor(keyBytes);
}
}
Add these properties to your application.properties:
application.security.jwt.secret-key=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
application.security.jwt.expiration=86400000 # 24 hours
Note: The secret-key above is just an example. Never hardcode sensitive keys in production. Use environment variables or a secure vault. It should be a long, randomly generated Base64-encoded string.
4. JWT Authentication Filter
This filter will intercept every request, check for a JWT, validate it, and authenticate the user if valid. Create JwtAuthenticationFilter.java:
import com.example.jwt.service.JwtService;
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)
);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}
This filter extracts the JWT from the Authorization header, validates it using JwtService, and if valid, sets the authenticated user in Spring's SecurityContextHolder, making them available to subsequent filters and controllers.
5. Authentication Controller
This controller will expose an endpoint for users to log in and receive a JWT.
import com.example.jwt.auth.AuthenticationRequest;
import com.example.jwt.auth.AuthenticationResponse;
import com.example.jwt.auth.RegisterRequest;
import com.example.jwt.service.AuthenticationService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationService authenticationService;
@PostMapping("/register")
public ResponseEntity<AuthenticationResponse> register(
@RequestBody RegisterRequest request
) {
return ResponseEntity.ok(authenticationService.register(request));
}
@PostMapping("/login")
public ResponseEntity<AuthenticationResponse> authenticate(
@RequestBody AuthenticationRequest request
) {
return ResponseEntity.ok(authenticationService.authenticate(request));
}
}
We'll need some DTOs for requests and responses, and an AuthenticationService:
// RegisterRequest.java
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class RegisterRequest {
private String firstname;
private String lastname;
private String email;
private String password;
}
// AuthenticationRequest.java
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthenticationRequest {
private String email;
private String password;
}
// AuthenticationResponse.java
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthenticationResponse {
private String token;
}
// AuthenticationService.java
import com.example.jwt.auth.AuthenticationRequest;
import com.example.jwt.auth.AuthenticationResponse;
import com.example.jwt.auth.RegisterRequest;
import com.example.jwt.service.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class AuthenticationService {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
public AuthenticationResponse register(RegisterRequest request) {
// For this intro, we'll just mock registration by creating a user and generating a token
// In a real app, save to DB etc.
var user = User.builder()
.username(request.getEmail())
.password(passwordEncoder.encode(request.getPassword()))
.roles("USER")
.build();
// In a real app: save user to repository
var jwtToken = jwtService.generateToken(user);
return AuthenticationResponse.builder()
.token(jwtToken)
.build();
}
public AuthenticationResponse authenticate(AuthenticationRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(),
request.getPassword()
)
);
var user = userDetailsService.loadUserByUsername(request.getEmail());
var jwtToken = jwtService.generateToken(user);
return AuthenticationResponse.builder()
.token(jwtToken)
.build();
}
}
6. A Protected Endpoint
Finally, let's create a simple endpoint that requires authentication to test our setup.
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/demo")
public class DemoController {
@GetMapping
public ResponseEntity<String> sayHello() {
return ResponseEntity.ok("Hello from secured endpoint!");
}
}
Testing Your Setup
1. Start your Spring Boot application.
2. Register (Optional) / Login: Send a POST request to http://localhost:8080/api/auth/login with JSON body: {"email": "user@example.com", "password": "password"}. You should receive a JWT in the response.
3. Access Protected Endpoint: Send a GET request to http://localhost:8080/api/demo. In the request headers, add Authorization: Bearer YOUR_JWT_TOKEN_HERE (replace YOUR_JWT_TOKEN_HERE with the token you received). You should get a 200 OK response with "Hello from secured endpoint!".
4. If you try to access /api/demo without the token or with an invalid token, you should get a 403 Forbidden or 401 Unauthorized response.
Conclusion
Congratulations! You've successfully set up a basic, yet powerful, JWT authentication system using Spring Security 6. You now have a solid foundation for securing your stateless APIs. We've covered the fundamental concepts of JWT, configured Spring Security to be stateless, implemented a custom JWT filter, and created a service to manage tokens.
While this setup is functional, there's always more to learn and optimize. In the next post in this series, we'll dive into best practices for JWT security, including token revocation, refresh tokens, and secure storage, to make your application even more robust. Stay tuned!