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
โš ๏ธ
JWT Payload Is Encoded - Not Encrypted

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.

XML - pom.xml Dependencies for JWT Auth
<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:

YAML - application.yml JWT Configuration
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.

Java - JwtUtils: Generate and Validate JWT Tokens
@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);
}
}
๐Ÿ”
Catch JwtException in isTokenValid - Not Just Expiry

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.

Java - User Entity and UserDetailsService Implementation
// 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.

Java - JwtAuthFilter: Validates JWT on Every Request
@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);
}
}
๐Ÿ“Œ
Why Check SecurityContextHolder Before Setting Auth

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.

Java - SecurityConfig with SecurityFilterChain
@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:

Java - Auth Controller: Register and Login 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.

Java - @PreAuthorize for Method-Level Security
@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);
}
}
๐Ÿ’ก
@PreAuthorize Throws 403 - Handle It in @RestControllerAdvice

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.

๐Ÿ’ก
Use Postman Environment Variables for JWT Tokens

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:

External Resources:

๐Ÿš€ Build a Fully Secured Spring Boot App

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.

Module 1
Spring Boot Foundations Auto-configuration, application.yml, JPA entities, relationships and Spring Data repositories
Module 2
Building Full REST APIs with Spring Boot Service layer, controller layer, pagination, sorting, DTO mapping and practical API design patterns
Module 3
Spring Security & JWT Spring Security 6 filter chain, password encoding, role-based access, @PreAuthorize, JWT generation, validation and secured endpoints - the full implementation from this tutorial
Module 4
Final Application Build Integrating all layers, validation with security, logging, debugging, code cleanup and preparing for real-world deployment
Start Learning on Coursera โ†’

โœ“ 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.

Web Development Spring Framework