JWT Authentication in Spring Boot: A Complete Step-by-Step Tutorial
Every production REST API needs authentication. And now a days, JWT - JSON Web Token - is the standard approach for securing Spring Boot APIs. Unlike session-based auth, JWT is stateless: the server doesn't store any session data. The token carries everything needed to verify the user on every request. This makes JWT perfect for microservices, mobile backends, and any API that needs to scale horizontally.
But implementing JWT in Spring Boot intimidates a lot of developers. Spring Security 6 changed the configuration style significantly, most tutorials are outdated or incomplete, and the pieces - token generation, filter chain, UserDetails, SecurityContext - need to work together precisely or nothing works at all. This tutorial gives you the complete implementation: every class, every configuration decision, and every reason why each piece exists.
By the end of this tutorial, you'll have a fully working JWT authentication system in Spring Boot - login endpoint that returns a token, a filter that validates the token on every subsequent request, role-based access control, and Postman tests that verify the entire flow. For background on Spring Security fundamentals, Board Infinity's post on understanding servlets in Java and OOP concepts in Java are ideal prerequisites - Spring Security's filter chain is built directly on Java's servlet model, and its entire DI and interface architecture is built on OOP.
Prerequisites
Before starting this tutorial, you need:
- Java 17+ and Maven installed
- Basic Spring Boot knowledge (controllers, services, JPA)
- Familiarity with HTTP methods and status codes
- Postman or any API testing tool installed
- Solid Java fundamentals - Board Infinity's core Java concepts and syntax guide covers the Java essentials that underpin every class in this tutorial
1. How JWT Works: Header, Payload, Signature Explained
Before writing a single line of code, understand what a JWT token actually is. A JWT is a Base64-encoded string with three parts separated by dots: header.payload.signature.
Header - contains the token type (JWT) and the signing algorithm (HS256). Payload - contains claims: the username, roles, issue time, and expiry. Signature - a cryptographic hash of the header and payload, signed with your secret key. The server uses this to verify the token wasn't tampered with.
When a client sends Authorization: Bearer eyJhbGc..., your JwtFilter decodes the token, verifies the signature, checks expiry, extracts the username, and loads the user's details - all without touching a database or session store. The Stream operations used to extract roles from the token - getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()) - apply the Java Streams and method references pattern covered in Board Infinity's map stream in Java guide.
| JWT Part | Content | Encoded? | Purpose |
|---|---|---|---|
| Header | {"alg":"HS256","typ":"JWT"} |
Base64URL | Describes the token type and signing algorithm |
| Payload | {"sub":"user@email.com","roles":["USER"],"exp":1234567890} |
Base64URL | Contains user claims - readable but not secure |
| Signature | HMAC-SHA256(header + "." + payload, secret) | Base64URL | Cryptographic proof token wasn't tampered with |
The payload section of a JWT is Base64URL encoded - which means anyone can decode it and read its contents. Never put sensitive data like passwords, payment details, or personal identifiers in the payload. The signature only proves the token wasn't modified - it doesn't hide the payload. For sensitive payloads, use JWE (JSON Web Encryption) instead of JWT.
2. Adding Spring Security to Your Spring Boot Project
Start with a fresh Spring Boot project or add the security and JWT dependencies to an existing one. You need three dependencies: Spring Security, the JJWT library (for JWT generation and validation), and Spring Validation. Understanding what Spring Boot auto-configures when you add spring-boot-starter-security - and why it locks all endpoints immediately - connects to Board Infinity's essentials of back-end development guide, which explains how backend security layers work alongside the API and data layers.
<dependencies> <!-- Spring Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Spring Security --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <!-- JJWT - JWT generation and validation --> <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> <!-- Validation --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>
Now add the JWT secret and expiry configuration to application.yml:
app:
jwt:
secret: "404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970"
# Generate your own: openssl rand -hex 32
expiry-ms: 86400000 # 24 hours in milliseconds
3. Creating the JwtUtils Class: Generating and Validating Tokens
JwtUtils is the core of your JWT implementation. It handles two responsibilities: generating tokens when a user logs in, and validating tokens when requests come in. Keep this class focused - it should do nothing but JWT operations.
The @Value("${app.jwt.secret}") and @Value("${app.jwt.expiry-ms}") annotations inject configuration from application.yml into private fields. The private access modifier on these fields - protecting them from direct external access - is the encapsulation principle applied in a real security context. Board Infinity's guide on abstraction vs encapsulation in Java covers why this matters: the jwtSecret must be private because exposing it would compromise the entire token verification system. The exception handling in isTokenValid() - catching JwtException and returning false rather than propagating the exception - applies the pattern covered in Board Infinity's throw and throws in Java guide.
@Component public class JwtUtils { @Value("${app.jwt.secret}") private String jwtSecret; @Value("${app.jwt.expiry-ms}") private long jwtExpiryMs; // Generate a JWT token for a successfully authenticated user public String generateToken(UserDetails userDetails) { return Jwts.builder() .subject(userDetails.getUsername()) .claim("roles", userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList())) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + jwtExpiryMs)) .signWith(getSigningKey()) .compact(); } // Extract username (subject) from a token public String extractUsername(String token) { return extractClaims(token).getSubject(); } // Check if token is valid for the given user and not expired public boolean isTokenValid(String token, UserDetails userDetails) { try { final String username = extractUsername(token); return username.equals(userDetails.getUsername()) && !isTokenExpired(token); } catch (JwtException e) { return false; // invalid signature, malformed, etc. } } private boolean isTokenExpired(String token) { return extractClaims(token).getExpiration().before(new Date()); } private Claims extractClaims(String token) { return Jwts.parser() .verifyWith(getSigningKey()) .build() .parseSignedClaims(token) .getPayload(); } private SecretKey getSigningKey() { byte[] keyBytes = Decoders.BASE64.decode(jwtSecret); return Keys.hmacShaKeyFor(keyBytes); } }
Wrapping extractClaims in a try-catch for JwtException handles all token failure cases: expired tokens, malformed tokens, invalid signatures, and unsupported token types. Without this catch, a malformed token in the Authorization header would throw an unhandled exception that bypasses your global error handler and returns a confusing 500 error instead of a clean 401.
4. Setting Up UserDetailsService
Spring Security needs a way to load user data from your database. UserDetailsService is the interface that provides this - implement it to tell Spring how to find a user by username.
The UserDetailsServiceImpl implements UserDetailsService pattern is one of the clearest examples of interface-based abstraction in the entire Spring ecosystem. Board Infinity's multiple inheritance in Java guide explains why Spring Security is designed around interfaces rather than concrete classes: UserDetailsService defines the contract (load a user by username), your implementation provides the logic (query your database), and Spring Security calls the interface without caring about your specific implementation. The Optional handling in loadUserByUsername - .orElseThrow(() -> new UsernameNotFoundException(...)) - applies the Optional pattern covered in Board Infinity's Java List and collections guides. The wrapper class in Java guide is also relevant for the Long id in the User entity - JPA requires Long (nullable wrapper) over long (primitive) to correctly detect unsaved entities.
// User entity - stored in database @Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String email; @Column(nullable = false) private String password; // stored as BCrypt hash @Enumerated(EnumType.STRING) private Role role; // ROLE_USER, ROLE_ADMIN // getters and setters... } public enum Role { ROLE_USER, ROLE_ADMIN } public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByEmail(String email); } // UserDetailsService - Spring Security calls this to load user by username @Service public class UserDetailsServiceImpl implements UserDetailsService { private final UserRepository userRepository; public UserDetailsServiceImpl(UserRepository userRepository) { this.userRepository = userRepository; } @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { User user = userRepository.findByEmail(email) .orElseThrow(() -> new UsernameNotFoundException("User not found: " + email)); return org.springframework.security.core.userdetails.User.builder() .username(user.getEmail()) .password(user.getPassword()) // already BCrypt hashed in DB .roles(user.getRole().name().replace("ROLE_", "")) // Spring adds ROLE_ prefix .build(); } }
5. Building the JwtFilter: Intercepting Every Request
The JwtAuthFilter runs on every incoming HTTP request - before your controller methods. It reads the Authorization header, validates the JWT, and if valid, sets the authenticated user in Spring Security's SecurityContext. This is what makes the rest of the request "know" who the user is. Extend OncePerRequestFilter - this guarantees the filter runs exactly once per request.
The inheritance used here - JwtAuthFilter extends OncePerRequestFilter and overrides doFilterInternal - is the same Java inheritance and method override pattern covered in Board Infinity's overloading vs overriding guide. The @Override annotation on doFilterInternal is not optional - it guarantees at compile time that you're overriding the correct method signature from OncePerRequestFilter. Getting the method signature wrong without @Override would silently create a new method rather than override the filter behavior, and the JWT filter would never run. The understanding servlets in Java guide also provides critical context - HttpServletRequest, HttpServletResponse, and FilterChain are all Java servlet interfaces that doFilterInternal works with.
@Component public class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtils jwtUtils; private final UserDetailsService userDetailsService; public JwtAuthFilter(JwtUtils jwtUtils, UserDetailsService uds) { this.jwtUtils = jwtUtils; this.userDetailsService = uds; } @Override protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // Step 1: Read the Authorization header final String authHeader = request.getHeader("Authorization"); // Step 2: If no Bearer token, skip - let Spring Security handle it if (authHeader == null || !authHeader.startsWith("Bearer ")) { filterChain.doFilter(request, response); return; } // Step 3: Extract the token (remove "Bearer " prefix) final String jwt = authHeader.substring(7); final String username = jwtUtils.extractUsername(jwt); // Step 4: If username found and no existing auth in context if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { // Step 5: Load user details from DB UserDetails userDetails = userDetailsService.loadUserByUsername(username); // Step 6: Validate token against loaded user if (jwtUtils.isTokenValid(jwt, userDetails)) { // Step 7: Create authentication token and set in SecurityContext UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( userDetails, null, // credentials - null after auth userDetails.getAuthorities() // roles from UserDetails ); authToken.setDetails( new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); // From here, the request is "authenticated" for this thread } } // Step 8: Continue to next filter / controller filterChain.doFilter(request, response); } }
The condition SecurityContextHolder.getContext().getAuthentication() == null prevents overwriting an existing authentication. If the request already has a valid session or another filter set authentication, your JWT filter won't overwrite it. This is a safety check that prevents unexpected behaviour in mixed authentication scenarios.
6. Configuring SecurityFilterChain: Protecting Routes
The SecurityFilterChain is where you define which routes are public, which require authentication, and which require specific roles. In Spring Security 6, this uses a clean lambda-based API.
The SecurityFilterChain configuration uses the same access control hierarchy you'd apply to any role-based system: roles in the JWT map to ROLE_ADMIN or ROLE_USER, and hasRole("ADMIN") checks those roles on every request. The Set<GrantedAuthority> that carries these roles internally uses Java's HashSet - Board Infinity's HashSet in Java guide explains why sets are used for authorities: roles must be unique (no duplicate ROLE_ADMIN in a user's authority list), and HashSet guarantees uniqueness automatically. The access modifiers in Java guide is relevant to the SecurityConfig class design - understanding private, protected, and public at the Java level maps directly to thinking about permitAll(), authenticated(), and hasRole() at the API access control level.
@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; private final UserDetailsServiceImpl userDetailsService; public SecurityConfig(JwtAuthFilter jwtFilter, UserDetailsServiceImpl uds) { this.jwtAuthFilter = jwtFilter; this.userDetailsService = uds; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth // Public endpoints - no token required .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/auth/register").permitAll() .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll() // Admin-only endpoints .requestMatchers("/api/admin/**").hasRole("ADMIN") // All other requests need a valid JWT .anyRequest().authenticated()) // Register our JWT filter before Spring's default auth filter .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public AuthenticationManager authenticationManager( AuthenticationConfiguration config) throws Exception { return config.getAuthenticationManager(); } }
Now create the authentication controller with login and register endpoints:
// Request and response records public record LoginRequest(@NotBlank String email, @NotBlank String password) {} public record RegisterRequest(@Email String email, @Size(min=8) String password) {} public record AuthResponse(String token, String email, String role) {} @RestController @RequestMapping("/api/auth") public class AuthController { private final AuthenticationManager authManager; private final UserDetailsServiceImpl userDetailsService; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtUtils jwtUtils; public AuthController(AuthenticationManager am, UserDetailsServiceImpl uds, UserRepository ur, PasswordEncoder pe, JwtUtils ju) { this.authManager = am; this.userDetailsService = uds; this.userRepository = ur; this.passwordEncoder = pe; this.jwtUtils = ju; } @PostMapping("/login") public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest req) { // Throws BadCredentialsException if invalid - caught by @RestControllerAdvice authManager.authenticate( new UsernamePasswordAuthenticationToken(req.email(), req.password())); // Authentication succeeded - generate token UserDetails userDetails = userDetailsService.loadUserByUsername(req.email()); String token = jwtUtils.generateToken(userDetails); return ResponseEntity.ok(new AuthResponse(token, req.email(), "ROLE_USER")); } @PostMapping("/register") public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest req) { if (userRepository.findByEmail(req.email()).isPresent()) { throw new EmailAlreadyExistsException("Email already registered"); } User newUser = new User(); newUser.setEmail(req.email()); newUser.setPassword(passwordEncoder.encode(req.password())); // BCrypt hash newUser.setRole(Role.ROLE_USER); userRepository.save(newUser); UserDetails userDetails = userDetailsService.loadUserByUsername(req.email()); String token = jwtUtils.generateToken(userDetails); return ResponseEntity.status(201).body(new AuthResponse(token, req.email(), "ROLE_USER")); } }
7. Role-Based Access with @PreAuthorize
With @EnableMethodSecurity in your security config, you can protect individual methods with @PreAuthorize - regardless of URL pattern rules. This is more flexible than URL-based rules because it protects at the method level, works even if the URL changes, and can use complex Spring Expression Language (SpEL) expressions.
The @PreAuthorize("hasRole('ADMIN')") and @PreAuthorize("#email == authentication.principal.username") expressions are evaluated at runtime using Java reflection - the same mechanism that powers Spring's entire annotation-driven architecture. Board Infinity's singleton class in Java guide provides useful context here - SecurityContextHolder uses the Singleton pattern to hold one Authentication object per thread, which is exactly what authentication.principal.username in the SpEL expression accesses. Understanding that the authentication object in @PreAuthorize expressions comes from the thread-local SecurityContextHolder makes method security expressions intuitive rather than mysterious.
@RestController @RequestMapping("/api/products") public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService = productService; } // Public - any user, no token needed (matched in SecurityFilterChain) @GetMapping public List<ProductDto> getAll() { return productService.findAll(); } // Any authenticated user - needs valid JWT @GetMapping("/{id}") @PreAuthorize("isAuthenticated()") public ResponseEntity<ProductDto> getById(@PathVariable Long id) { return ResponseEntity.ok(productService.findById(id)); } // ADMIN only - must have ROLE_ADMIN in JWT claims @PostMapping @PreAuthorize("hasRole('ADMIN')") public ResponseEntity<ProductDto> create(@Valid @RequestBody CreateProductRequest req) { return ResponseEntity.status(201).body(productService.create(req)); } @DeleteMapping("/{id}") @PreAuthorize("hasRole('ADMIN')") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable Long id) { productService.delete(id); } // Access own resource only - uses JWT subject (email) in expression @GetMapping("/my-orders") @PreAuthorize("#email == authentication.principal.username") public List<OrderDto> myOrders(@RequestParam String email) { return orderService.findByEmail(email); } }
When a user with insufficient role tries to access a @PreAuthorize-protected endpoint, Spring throws AccessDeniedException. Without handling it, Spring Security returns a default 403 with a non-JSON body. Add a handler to your @RestControllerAdvice: @ExceptionHandler(AccessDeniedException.class) returning ResponseEntity.status(403).body(new ApiError(403, "Access denied")). Similarly handle AuthenticationException for 401 responses on missing/invalid tokens.
8. Testing JWT Endpoints with Postman
With the implementation complete, here's the exact Postman flow to verify everything works.
Step 1 - Register a new user: POST /api/auth/register with {"email": "user@test.com", "password": "password123"} - Expected: 201 with JWT token.
Step 2 - Login and get a token: POST /api/auth/login - Expected: 200 with {"token": "eyJhbGc...", "email": "user@test.com", "role": "ROLE_USER"}.
Step 3 - Access a protected endpoint: GET /api/products/1 with Authorization: Bearer {token} - Expected: 200 OK.
Step 4 - Test without token: Same request with no Authorization header - Expected: 401 Unauthorized.
Step 5 - Test wrong role: POST to /api/products with a USER token - Expected: 403 Forbidden. Use jwt.io to inspect your token contents during testing. For context on the full back-end architecture that this JWT security layer protects, Board Infinity's Essentials of Back-End Development: From APIs to Databases covers how authentication integrates with the database and API layers in a complete Spring Boot application.
In Postman, create a collection variable called jwt_token. In your login request's Tests tab, add: pm.collectionVariables.set("jwt_token", pm.response.json().token);. Then in all other requests, set the Authorization header to Bearer {{jwt_token}}. Now when you log in, the token is automatically available for all other requests in the collection - no copy-pasting needed.
Further Reading
Board Infinity Guides:
- OOP Concepts in Java
- Core Java Concepts and Syntax
- Classes and Objects in Java
- Abstraction vs Encapsulation in Java
- Abstraction in Java
- Understanding Polymorphism in Java
- Multiple Inheritance in Java - Interfaces
- Access Modifiers in Java
- Understanding Singleton Class in Java
- Understanding Servlets in Java
- Method Overloading vs Overriding in Java
- Learn About Throw and Throws in Java
- Understand HashSet in Java
- Learn About Java List
- Learn About Map Stream in Java
- Generics in Java
- Understanding Wrapper Class in Java
- Essentials of Back-End Development: From APIs to Databases
External Resources:
- Spring Security Official Documentation - JWT
- JJWT - Java JWT Library Documentation
- jwt.io - JWT Token Inspector and Debugger
Spring Boot, Spring Security & Application Finalization on Coursera
This free Coursera course by Board Infinity takes the JWT implementation in this tutorial and integrates it into a complete, production-ready application - with JPA, full REST APIs, pagination, role-based access, and clean code practices. You'll build the full app from scratch, not just the security layer.
โ Certificate available ยท โ Self-paced ยท โ Beginner-friendly
Conclusion
JWT authentication in Spring Boot follows a clear, repeatable pattern. JwtUtils generates and validates tokens. UserDetailsService loads user data for Spring Security. JwtAuthFilter intercepts every request, validates the token, and sets the security context. SecurityFilterChain defines which routes are public and which require authentication or specific roles. @PreAuthorize provides method-level access control for fine-grained permissions.
The five classes you built in this tutorial - JwtUtils, UserDetailsServiceImpl, JwtAuthFilter, SecurityConfig, and AuthController - form a complete, production-ready JWT authentication system. The pattern is consistent across Spring Boot versions and scales from a small API to a large microservices architecture.
From here, the natural enhancements are refresh token support (so users don't need to log in every 24 hours), token revocation via a blacklist, and extracting user claims from the JWT directly rather than loading from the database on every request. All of these build directly on the foundation established in this tutorial. The Java fundamentals underlying all of it - OOP, interfaces, collections, streams, encapsulation, exception handling - are covered comprehensively in Board Infinity's OOP concepts in Java guide and generics in Java guide, which explains the typed collections (List<GrantedAuthority>, Optional<User>) that appear throughout the security implementation.