JWT Authentication and Refresh Tokens in Spring Boot 3.5: A Complete Guide

Introduction

JSON Web Tokens (JWTs) have become the de facto standard for securing stateless REST APIs. Unlike traditional session-based authentication — where the server maintains a session store — JWT authentication is fully self-contained: the token carries all the information needed to verify a user's identity, signed with a secret key only the server knows.

But there is a fundamental tension. Access tokens must expire quickly to limit damage if stolen (15 minutes is the standard sweet spot), yet you do not want users to re-enter their password every quarter hour. That is where refresh tokens come in: long-lived credentials stored server-side that let clients silently obtain new access tokens without user intervention.

In this tutorial, you will build a production-ready JWT auth system using:

  • Spring Boot 3.5.13 with Spring Security 6.5
  • JJWT 0.14.0 — the latest stable Java JWT library
  • Short-lived access tokens (15 min) signed with HMAC-SHA256
  • Long-lived refresh tokens (7 days) stored as SHA-256 hashes in MySQL
  • Refresh token rotation — each use produces a new token pair; the old token is immediately invalidated
  • Reuse detection — if an already-consumed refresh token is presented again, all sessions for that user are revoked instantly

Version note: Spring Boot 3.5 reached open-source EOL in June 2026. Spring Boot 4.1 is the current actively supported line. All code in this tutorial compiles unchanged under either version — the Spring Security 6 SecurityFilterChain bean pattern carries over to Security 7.

Prerequisites

  • Java 21 (LTS)
  • Maven 3.9+
  • MySQL 8.0+ (H2 is used for tests)
  • Familiarity with Spring Boot and REST APIs

Project Setup

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.13</version>
        <relativePath/>
    </parent>

    <groupId>com.vimleshpandey.demo</groupId>
    <artifactId>jwt-auth-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>jwt-auth-demo</name>
    <description>JWT Authentication with Refresh Tokens in Spring Boot 3.5</description>

    <properties>
        <java.version>21</java.version>
        <jjwt.version>0.14.0</jjwt.version>
    </properties>

    <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.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- JJWT 0.14.0: three-artifact split; impl + jackson are runtime-only -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>

        <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>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

JJWT 0.14.0 ships as three separate artifacts. Only compile against jjwt-api; mark jjwt-impl and jjwt-jackson as runtime. JJWT 0.14.0 also fixes CVE-2024-31033 (key-derivation weakness in versions ≤ 0.12.5).

application.yml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/jwt_demo?createDatabaseIfNotExist=true&useSSL=false&serverTimezone=UTC
    username: root
    password: your_password
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false

app:
  jwt:
    secret: "your-secret-key-must-be-at-least-32-characters-long"
    access-token-expiry-ms: 900000    # 15 minutes
    refresh-token-expiry-days: 7

Production tip: Never hardcode the JWT secret. Inject it via an environment variable (JWT_SECRET) or a secrets manager (AWS Secrets Manager, HashiCorp Vault). Generate a strong key with openssl rand -base64 32.

Project Structure

src/main/java/com/vimleshpandey/demo/
├── JwtAuthDemoApplication.java
├── config/
│   └── SecurityConfig.java
├── controller/
│   └── AuthController.java
├── dto/
│   ├── AuthResponse.java
│   ├── LoginRequest.java
│   ├── RefreshRequest.java
│   └── RegisterRequest.java
├── entity/
│   ├── RefreshToken.java
│   └── User.java
├── exception/
│   ├── GlobalExceptionHandler.java
│   └── TokenException.java
├── repository/
│   ├── RefreshTokenRepository.java
│   └── UserRepository.java
├── security/
│   ├── JwtAuthenticationFilter.java
│   ├── JwtService.java
│   └── RefreshTokenService.java
└── service/
    ├── AuthService.java
    └── CustomUserDetailsService.java

Domain Model

User Entity

The User entity implements UserDetails directly. This is the cleanest approach for a greenfield app and avoids an adapter class.

package com.vimleshpandey.demo.entity;

import jakarta.persistence.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;

@Entity
@Table(name = "users")
public class User implements UserDetails {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role = Role.USER;

    public enum Role { USER, ADMIN }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
    }

    @Override public String getPassword()                  { return password; }
    @Override public String getUsername()                  { return username; }
    @Override public boolean isAccountNonExpired()         { return true; }
    @Override public boolean isAccountNonLocked()          { return true; }
    @Override public boolean isCredentialsNonExpired()     { return true; }
    @Override public boolean isEnabled()                   { return true; }

    public Long getId()                    { return id; }
    public void setId(Long id)             { this.id = id; }
    public void setUsername(String u)      { this.username = u; }
    public String getEmail()               { return email; }
    public void setEmail(String e)         { this.email = e; }
    public void setPassword(String p)      { this.password = p; }
    public Role getRole()                  { return role; }
    public void setRole(Role r)            { this.role = r; }
}

RefreshToken Entity

We store only the SHA-256 hash of each refresh token — never the raw value. If the database is compromised, stored hashes cannot be replayed.

package com.vimleshpandey.demo.entity;

import jakarta.persistence.*;
import java.time.Instant;

@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 64)
    private String tokenHash;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(nullable = false)
    private Instant expiresAt;

    @Column(nullable = false)
    private boolean revoked = false;

    public Long getId()                        { return id; }
    public String getTokenHash()               { return tokenHash; }
    public void setTokenHash(String h)         { this.tokenHash = h; }
    public User getUser()                      { return user; }
    public void setUser(User u)                { this.user = u; }
    public Instant getExpiresAt()              { return expiresAt; }
    public void setExpiresAt(Instant t)        { this.expiresAt = t; }
    public boolean isRevoked()                 { return revoked; }
    public void setRevoked(boolean r)          { this.revoked = r; }
}

Repositories

package com.vimleshpandey.demo.repository;

import com.vimleshpandey.demo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
    boolean existsByUsername(String username);
    boolean existsByEmail(String email);
}
package com.vimleshpandey.demo.repository;

import com.vimleshpandey.demo.entity.RefreshToken;
import com.vimleshpandey.demo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import java.time.Instant;
import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {

    Optional<RefreshToken> findByTokenHash(String tokenHash);

    @Modifying
    @Query("UPDATE RefreshToken r SET r.revoked = true WHERE r.user = :user")
    void revokeAllByUser(User user);

    @Modifying
    @Query("DELETE FROM RefreshToken r WHERE r.expiresAt < :now OR r.revoked = true")
    void deleteExpiredAndRevoked(Instant now);
}

JWT Service

With JJWT 0.14.0, the parsing API uses the fluent builder via Jwts.parser().verifyWith(key).build(). The old Jwts.parserBuilder() is removed in this release.

package com.vimleshpandey.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.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtService {

    private final SecretKey signingKey;
    private final long accessTokenExpiryMs;

    public JwtService(
            @Value("${app.jwt.secret}") String secret,
            @Value("${app.jwt.access-token-expiry-ms}") long accessTokenExpiryMs) {
        this.signingKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.accessTokenExpiryMs = accessTokenExpiryMs;
    }

    public String generateAccessToken(UserDetails userDetails) {
        return generateAccessToken(new HashMap<>(), userDetails);
    }

    public String generateAccessToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder()
                .claims(extraClaims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + accessTokenExpiryMs))
                .signWith(signingKey, Jwts.SIG.HS256)
                .compact();
    }

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    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 extractClaim(token, Claims::getExpiration).before(new Date());
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        return claimsResolver.apply(extractAllClaims(token));
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(signingKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}

Refresh Token Service

This is the engine of the rotation strategy. The validateAndRotate method is the critical path: it atomically validates, revokes the old token, and signals the caller to issue a new pair.

package com.vimleshpandey.demo.security;

import com.vimleshpandey.demo.entity.RefreshToken;
import com.vimleshpandey.demo.entity.User;
import com.vimleshpandey.demo.exception.TokenException;
import com.vimleshpandey.demo.repository.RefreshTokenRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import java.util.UUID;

@Service
public class RefreshTokenService {

    private final RefreshTokenRepository repo;
    private final long expiryDays;

    public RefreshTokenService(RefreshTokenRepository repo,
                               @Value("${app.jwt.refresh-token-expiry-days}") long expiryDays) {
        this.repo = repo;
        this.expiryDays = expiryDays;
    }

    @Transactional
    public String createRefreshToken(User user) {
        String raw = UUID.randomUUID().toString();

        RefreshToken token = new RefreshToken();
        token.setTokenHash(sha256(raw));
        token.setUser(user);
        token.setExpiresAt(Instant.now().plus(expiryDays, ChronoUnit.DAYS));
        token.setRevoked(false);
        repo.save(token);

        return raw; // Raw value sent to client; hash stored in DB
    }

    @Transactional
    public RefreshToken validateAndRotate(String raw) {
        String hash = sha256(raw);
        RefreshToken stored = repo.findByTokenHash(hash)
                .orElseThrow(() -> new TokenException("Refresh token not found"));

        if (stored.isRevoked()) {
            // Reuse detected: revoke ALL of this user's sessions
            repo.revokeAllByUser(stored.getUser());
            throw new TokenException("Refresh token reuse detected — all sessions revoked");
        }

        if (stored.getExpiresAt().isBefore(Instant.now())) {
            throw new TokenException("Refresh token has expired");
        }

        // Rotate: mark old token consumed
        stored.setRevoked(true);
        repo.save(stored);
        return stored;
    }

    @Transactional
    public void revokeAllUserTokens(User user) {
        repo.revokeAllByUser(user);
    }

    private String sha256(String value) {
        try {
            byte[] hash = MessageDigest.getInstance("SHA-256")
                    .digest(value.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("SHA-256 unavailable", e);
        }
    }
}

Security Configuration

Spring Security 6 permanently removed WebSecurityConfigurerAdapter. The modern approach is a plain @Bean that returns a SecurityFilterChain.

package com.vimleshpandey.demo.config;

import com.vimleshpandey.demo.security.JwtAuthenticationFilter;
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.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final UserDetailsService userDetailsService;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthFilter,
                          UserDetailsService userDetailsService) {
        this.jwtAuthFilter = jwtAuthFilter;
        this.userDetailsService = userDetailsService;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .authenticationProvider(authenticationProvider())
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
            throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

CSRF is disabled because stateless JWT APIs do not use session cookies — the CSRF threat model does not apply here. STATELESS session policy means Spring Security never creates or reads an HTTP session.

JWT Authentication Filter

This filter intercepts every request, validates the Authorization: Bearer header, and populates the SecurityContext if the token is valid.

package com.vimleshpandey.demo.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
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
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain) throws ServletException, IOException {

        final String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            final String jwt = authHeader.substring(7);
            final String username = jwtService.extractUsername(jwt);

            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                if (jwtService.isTokenValid(jwt, userDetails)) {
                    var authToken = new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities());
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (Exception ignored) {
            // Malformed/expired token: leave SecurityContext unauthenticated
        }

        filterChain.doFilter(request, response);
    }
}

Auth Service and Controller

package com.vimleshpandey.demo.service;

import com.vimleshpandey.demo.dto.*;
import com.vimleshpandey.demo.entity.RefreshToken;
import com.vimleshpandey.demo.entity.User;
import com.vimleshpandey.demo.repository.UserRepository;
import com.vimleshpandey.demo.security.JwtService;
import com.vimleshpandey.demo.security.RefreshTokenService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AuthService {

    private final UserRepository userRepository;
    private final JwtService jwtService;
    private final RefreshTokenService refreshTokenService;
    private final AuthenticationManager authenticationManager;
    private final PasswordEncoder passwordEncoder;

    public AuthService(UserRepository userRepository, JwtService jwtService,
                       RefreshTokenService refreshTokenService,
                       AuthenticationManager authenticationManager,
                       PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.jwtService = jwtService;
        this.refreshTokenService = refreshTokenService;
        this.authenticationManager = authenticationManager;
        this.passwordEncoder = passwordEncoder;
    }

    @Transactional
    public AuthResponse register(RegisterRequest request) {
        if (userRepository.existsByUsername(request.username()))
            throw new IllegalArgumentException("Username already taken");
        if (userRepository.existsByEmail(request.email()))
            throw new IllegalArgumentException("Email already registered");

        User user = new User();
        user.setUsername(request.username());
        user.setEmail(request.email());
        user.setPassword(passwordEncoder.encode(request.password()));
        userRepository.save(user);

        return issueTokenPair(user);
    }

    public AuthResponse login(LoginRequest request) {
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.username(), request.password()));
        User user = userRepository.findByUsername(request.username()).orElseThrow();
        return issueTokenPair(user);
    }

    @Transactional
    public AuthResponse refresh(RefreshRequest request) {
        RefreshToken stored = refreshTokenService.validateAndRotate(request.refreshToken());
        return issueTokenPair(stored.getUser());
    }

    @Transactional
    public void logout(String username) {
        User user = userRepository.findByUsername(username).orElseThrow();
        refreshTokenService.revokeAllUserTokens(user);
    }

    private AuthResponse issueTokenPair(User user) {
        String accessToken = jwtService.generateAccessToken(user);
        String refreshToken = refreshTokenService.createRefreshToken(user);
        return new AuthResponse(accessToken, refreshToken);
    }
}

The DTOs use Java 16+ records for brevity:

package com.vimleshpandey.demo.dto;

import jakarta.validation.constraints.*;

public record RegisterRequest(
    @NotBlank @Size(min = 3, max = 50) String username,
    @NotBlank @Email                    String email,
    @NotBlank @Size(min = 8)            String password
) {}

public record LoginRequest(
    @NotBlank String username,
    @NotBlank String password
) {}

public record RefreshRequest(@NotBlank String refreshToken) {}

public record AuthResponse(String accessToken, String refreshToken, String tokenType) {
    public AuthResponse(String access, String refresh) {
        this(access, refresh, "Bearer");
    }
}

The controller wires everything together:

package com.vimleshpandey.demo.controller;

import com.vimleshpandey.demo.dto.*;
import com.vimleshpandey.demo.service.AuthService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/register")
    public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest req) {
        return ResponseEntity.ok(authService.register(req));
    }

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest req) {
        return ResponseEntity.ok(authService.login(req));
    }

    @PostMapping("/refresh")
    public ResponseEntity<AuthResponse> refresh(@Valid @RequestBody RefreshRequest req) {
        return ResponseEntity.ok(authService.refresh(req));
    }

    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@AuthenticationPrincipal UserDetails user) {
        authService.logout(user.getUsername());
        return ResponseEntity.noContent().build();
    }
}
EndpointPurpose
POST /api/auth/registerCreate account; returns token pair
POST /api/auth/loginAuthenticate; returns token pair
POST /api/auth/refreshExchange refresh token for a new pair
POST /api/auth/logoutRevoke all refresh tokens for current user

Testing

package com.vimleshpandey.demo;

import com.vimleshpandey.demo.security.JwtService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.test.context.ActiveProfiles;

import java.util.Collections;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@ActiveProfiles("test")
class JwtServiceTest {

    @Autowired
    private JwtService jwtService;

    @Test
    void tokenShouldBeValidForIssuedUser() {
        UserDetails user = new User("alice", "password", Collections.emptyList());
        String token = jwtService.generateAccessToken(user);
        assertThat(jwtService.isTokenValid(token, user)).isTrue();
    }

    @Test
    void extractedUsernameShouldMatchSubject() {
        UserDetails user = new User("bob", "password", Collections.emptyList());
        String token = jwtService.generateAccessToken(user);
        assertThat(jwtService.extractUsername(token)).isEqualTo("bob");
    }

    @Test
    void tokenShouldBeInvalidForDifferentUser() {
        UserDetails a = new User("alice", "password", Collections.emptyList());
        UserDetails b = new User("bob",   "password", Collections.emptyList());
        String token = jwtService.generateAccessToken(a);
        assertThat(jwtService.isTokenValid(token, b)).isFalse();
    }
}

Add src/test/resources/application-test.yml so tests use H2 instead of MySQL:

spring:
  datasource:
    url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
    username: sa
    password:
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop

app:
  jwt:
    secret: "test-secret-key-that-is-at-least-32-characters-long!!"
    access-token-expiry-ms: 3600000
    refresh-token-expiry-days: 7

Running the Application

# Start the app
mvn spring-boot:run

# Register
curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","email":"alice@example.com","password":"Password123!"}'
# {"accessToken":"eyJ...","refreshToken":"550e8400-e29b-...","tokenType":"Bearer"}

# Call a protected endpoint
curl http://localhost:8080/api/protected \
  -H "Authorization: Bearer eyJ..."

# Refresh the token pair
curl -X POST http://localhost:8080/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"550e8400-e29b-..."}'
# New token pair returned; old refresh token is now permanently revoked

# Logout (revokes all sessions)
curl -X POST http://localhost:8080/api/auth/logout \
  -H "Authorization: Bearer eyJ..."

Common Pitfalls and Tips

1. Never accept the none algorithm. JJWT 0.14.0 rejects it by default, but if you ever write a custom parser, always whitelist allowed algorithms explicitly. An attacker can forge tokens if the server accepts any algorithm the header dictates.

2. Avoid localStorage for refresh tokens. LocalStorage is accessible to any JavaScript on the page, making tokens vulnerable to XSS. Use HttpOnly; Secure; SameSite=Strict cookies for the refresh token.

3. Use a 256-bit minimum secret for HS256. A short secret is brute-forceable. Your app.jwt.secret must be at least 32 characters. Generate one with openssl rand -base64 32.

4. Hash refresh tokens in the database. Raw UUID tokens in plaintext are a single-breach disaster. SHA-256 hashing (used here) is fast and sufficient since tokens are already high-entropy.

5. Implement the @Scheduled cleanup job. Without periodic cleanup, the refresh_tokens table grows indefinitely. Add a scheduled task that calls repo.deleteExpiredAndRevoked(Instant.now()) nightly.

6. Test reuse detection. The most important security property is that a stolen refresh token cannot be silently reused. Write a test that calls /refresh with the same token twice and asserts the second call returns 401 and that all sessions are revoked.

7. Upgrade JJWT to 0.14.0 if you are on an older version. CVE-2024-31033 affects versions ≤ 0.12.5. The fix is included in 0.12.6+ and 0.14.0.

Conclusion

You now have a complete, production-ready JWT authentication system on Spring Boot 3.5 and Spring Security 6.5. The design — 15-minute access tokens, server-side hashed refresh tokens, rotation on every use, and reuse detection that revokes entire session families — is exactly what you find in high-traffic production APIs.

From here, consider:

  • Rate limiting the /login and /refresh endpoints (Bucket4j integrates cleanly with Spring Boot)

  • Redis for the refresh token store when sub-millisecond revocation checks at scale matter

  • Migrating to Spring Boot 4.1 (the current actively supported line) when your schedule allows — the Security 7 SecurityFilterChain API is identical

Download the complete project zip below and you will have a running app in under five minutes.