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/login to 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
PartContentEncoding
HeaderAlgorithm type (e.g., HS256)Base64URL
PayloadClaims (username, expiration, etc.)Base64URL
SignatureHeader and Payload signed together with a secret keyBinary 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.

ArtifactRole
jjwt-apiAPI interfaces
jjwt-implImplementation (runtime only)
jjwt-jacksonJSON 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.x series has a significantly different API. If you come across code online using Jwts.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.expiration is 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:

ExceptionMeaningResponse
ExpiredJwtExceptionToken has expiredReturn 401
MalformedJwtExceptionMalformed token formatReturn 401
SignatureExceptionInvalid signature (tampered)Return 401
UnsupportedJwtExceptionUnsupported JWT formatReturn 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 conflicting UserDetailsService Bean definition and prevent the application from starting.

Steps to migrate from in-memory to DB-backed:

  1. Remove the @Configuration annotation from UserDetailsConfig, or delete the class entirely
  2. Enable the @Service annotation on UserDetailsServiceImpl (already present in the code below)
  3. Move the PasswordEncoder @Bean definition to SecurityConfig or 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 @Component causes Spring Boot to auto-register it as a servlet filter, which combined with the addFilterBefore registration in SecurityFilterChain results in the filter being registered twice. Defining it as a @Bean inside SecurityConfig ensures 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.

StepImplementation
1Add jjwt dependencies
2Externalize secret key and expiration to application.properties
3Implement token generation and validation logic in JwtUtil
4Set up the user-loading foundation with UserDetailsService
5Implement the JWT issuance endpoint at /api/auth/login
6Implement per-request token validation in JwtAuthenticationFilter
7Configure STATELESS policy, Filter insertion, and authorization rules in SecurityFilterChain
8Run 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.