JWT Authentication and Refresh Tokens in Spring Boot 3.5 with Spring Security 6

Introduction

JSON Web Tokens (JWT) remain the most widely adopted mechanism for stateless authentication in REST APIs. With Spring Boot 3.5.6 and Spring Security 6.5.4, building a production-grade JWT system is cleaner than ever — the old WebSecurityConfigurerAdapter is gone, replaced by a concise lambda DSL, and JJWT 0.12.6 brings a modern, type-safe API that closes off an entire class of algorithm-confusion vulnerabilities.

In this tutorial, you will build a complete, runnable authentication service from scratch:

  • User registration and login with access token generation (15-minute HS256 JWTs)
  • Short-lived access tokens validated locally — no database call per request
  • Opaque UUID refresh tokens stored in MySQL — revocable at any time
  • Token rotation on every refresh: old token invalidated, fresh pair issued
  • Secure logout that immediately revokes all tokens for the user
  • Unit tests with MockitoExtension and integration-ready test patterns

All source files are bundled into the downloadable ZIP at the bottom of this post.

Prerequisites

  • Java 21 or higher
  • Maven 3.9+
  • MySQL 8.0+ running locally (or swap in H2 for a quick in-memory run — just change the driver)
  • Basic familiarity with Spring Boot and REST APIs
  • curl or Postman for testing

Understanding the Token Architecture

Before writing a single line of code, it's worth understanding why we use two token types.

Access Token (JWT, 15 minutes)

An access token is a signed JSON payload the server can validate cryptographically — no database lookup required. That makes it fast. The tradeoff is that it cannot be revoked before it expires. A token stolen from a user's browser session lives for up to 15 minutes regardless of what you do on the server.

For most applications, a 15-minute window is an acceptable risk. If your threat model requires shorter windows, 5 minutes is a reasonable choice.

Refresh Token (opaque UUID, 7 days)

A refresh token is a random string stored in the refresh_tokens table. When the user's access token expires, they present the refresh token to receive a fresh pair. Because it exists in the database, it can be revoked instantly — a logout operation simply deletes the row.

We also apply token rotation: every successful refresh issues a new refresh token and deletes the old one. This means a leaked refresh token can only be used once before it is invalidated by the legitimate user's next refresh.

This two-token pattern is the current OWASP-recommended approach for both browser-based SPAs and mobile applications.

Project Setup

Maven Dependencies

Create a new Spring Boot project and replace the generated pom.xml with the following. Pay close attention to the JJWT section — starting with 0.10.0, JJWT ships as three separate artifacts. The old single io.jsonwebtoken:jjwt jar is deprecated and should never be used.

<?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.6</version>
        <relativePath/>
    </parent>

    <groupId>com.vimleshpandey</groupId>
    <artifactId>jwt-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>jwt-demo</name>

    <properties>
        <java.version>21</java.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>

        <!-- JJWT — always use the three modular artifacts -->
        <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>

        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </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>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

jjwt-api is your compile-time dependency. jjwt-impl contains the actual implementation and must be runtime scope so your code never couples to internal APIs. jjwt-jackson handles JSON serialisation of claims.

Application Configuration

src/main/resources/application.yml:

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/jwt_demo?createDatabaseIfNotExist=true
    username: root
    password: your_password
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect

app:
  jwt:
    secret: bWFrZS10aGlzLWEtMzItYnl0ZS1zZWNyZXQta2V5ISE=
    expiration: 900000          # 15 minutes in milliseconds
    refresh-expiration: 604800000  # 7 days

Generating a secure secret in production: The placeholder above decodes to a 32-byte string. For a real deployment, generate a cryptographically random key once:

>
> SecretKey key = Jwts.SIG.HS256.key().build();
> String b64 = Encoders.BASE64.encode(key.getEncoded());
> System.out.println(b64); // store in Vault or environment variable
>

Never commit a real secret to version control.

Domain Model

User Entity

We implement UserDetails directly on the User entity, which is common for small to medium applications. Spring Security calls loadUserByUsername() on each authenticated request, and returning a JPA-managed entity from that call is clean and direct.

package com.vimleshpandey.demo.entity;

import jakarta.persistence.*;
import lombok.*;
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")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class User implements UserDetails {

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

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

    @Column(nullable = false)
    private String password;

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

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

    @Override public boolean isAccountNonExpired()     { return true; }
    @Override public boolean isAccountNonLocked()      { return true; }
    @Override public boolean isCredentialsNonExpired() { return true; }
    @Override public boolean isEnabled()               { return true; }
}
package com.vimleshpandey.demo.entity;

public enum Role { USER, ADMIN }

Refresh Token Entity

package com.vimleshpandey.demo.entity;

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

@Entity
@Table(name = "refresh_tokens")
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class RefreshToken {

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

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

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

    @Column(nullable = false)
    private Instant expiryDate;

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

The JWT Service

This is the core of the implementation. There is a critical API change in JJWT 0.12.x that trips up most developers who learned JWT on older tutorials.

JJWT 0.11.x → 0.12.x Migration (What Changed)

Old API (deprecated/removed)New API (0.12.x)
Jwts.parserBuilder()Jwts.parser()
.setSigningKey(key).verifyWith(key)
.parseClaimsJws(token).parseSignedClaims(token)
.getBody().getPayload()
.setSubject(s).subject(s)
.setExpiration(d).expiration(d)
signWith(key, algorithm)signWith(key) (algorithm inferred from key type)

The algorithm inference change is significant from a security standpoint: because the algorithm is derived from the key type rather than the token header, the entire class of algorithm confusion attacks is structurally impossible with JJWT 0.12.x.

package com.vimleshpandey.demo.service;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.util.*;
import java.util.function.Function;

@Service
public class JwtService {

    @Value("${app.jwt.secret}")
    private String secretKey;

    @Value("${app.jwt.expiration}")
    private long jwtExpiration;

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

    public String generateAccessToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return buildToken(extraClaims, userDetails, jwtExpiration);
    }

    private String buildToken(Map<String, Object> extraClaims,
                               UserDetails userDetails, long expiration) {
        return Jwts.builder()
                .claims(extraClaims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey())   // algorithm inferred from SecretKey type
                .compact();
    }

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

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

    public boolean isTokenValid(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    private boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

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

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

The JWT Authentication Filter

This filter runs on every incoming request. It extracts the Authorization: Bearer header, validates the JWT, and if valid, populates the SecurityContext so downstream handlers know who is making the request.

We extend OncePerRequestFilter — this guarantees the filter runs exactly once per request even in complex Spring filter chains where the same filter might otherwise be invoked multiple times (e.g., through forward dispatches).

package com.vimleshpandey.demo.filter;

import com.vimleshpandey.demo.service.JwtService;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

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

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

        final String jwt = authHeader.substring(7);
        try {
            final String username = jwtService.extractUsername(jwt);
            if (username != null &&
                    SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                if (jwtService.isTokenValid(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken =
                            new UsernamePasswordAuthenticationToken(
                                    userDetails, null, userDetails.getAuthorities());
                    authToken.setDetails(
                            new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (JwtException | IllegalArgumentException e) {
            // Malformed or tampered token — clear context and fall through to 401
            SecurityContextHolder.clearContext();
        }
        filterChain.doFilter(request, response);
    }
}

Notice the catch (JwtException | IllegalArgumentException e) block. JJWT throws a JwtException for any validation failure — expired token, bad signature, malformed structure. We catch it, clear the security context, and let the request fall through. Spring Security will then return a 401 because no authentication is present.

Spring Security Configuration

In Spring Security 6, WebSecurityConfigurerAdapter was permanently removed. If you extend it, your code will not compile. All security configuration is done through a SecurityFilterChain bean.

package com.vimleshpandey.demo.config;

import com.vimleshpandey.demo.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationProvider;
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.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AuthenticationProvider authenticationProvider;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)            // stateless — no CSRF needed
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                .anyRequest().authenticated())
            .authenticationProvider(authenticationProvider)
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}
package com.vimleshpandey.demo.config;

import com.vimleshpandey.demo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.*;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {

    private final UserRepository userRepository;

    @Bean
    public UserDetailsService userDetailsService() {
        return username -> userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
    }

    @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();
    }
}

DaoAuthenticationProvider is wired with our UserDetailsService and a BCryptPasswordEncoder. When authenticationManager.authenticate() is called in the login endpoint, this provider loads the user, hashes the incoming password, and compares it against the stored hash.

Refresh Token System

Repository

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 java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByToken(String token);
    void deleteByUser(User user);
}

Service

The @Transactional annotation on createRefreshToken is critical — we delete the existing token and insert a new one as a single atomic operation.

package com.vimleshpandey.demo.service;

import com.vimleshpandey.demo.entity.*;
import com.vimleshpandey.demo.exception.TokenRefreshException;
import com.vimleshpandey.demo.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.Optional;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class RefreshTokenService {

    @Value("${app.jwt.refresh-expiration}")
    private long refreshExpiration;

    private final RefreshTokenRepository refreshTokenRepository;
    private final UserRepository userRepository;

    @Transactional
    public RefreshToken createRefreshToken(String username) {
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException(username));
        refreshTokenRepository.deleteByUser(user);  // one active refresh token per user
        return refreshTokenRepository.save(RefreshToken.builder()
                .token(UUID.randomUUID().toString())
                .user(user)
                .expiryDate(Instant.now().plusMillis(refreshExpiration))
                .revoked(false)
                .build());
    }

    public RefreshToken verifyExpiration(RefreshToken token) {
        if (token.isRevoked() || token.getExpiryDate().isBefore(Instant.now())) {
            refreshTokenRepository.delete(token);
            throw new TokenRefreshException("Refresh token expired or revoked. Please log in again.");
        }
        return token;
    }

    public Optional<RefreshToken> findByToken(String token) {
        return refreshTokenRepository.findByToken(token);
    }

    @Transactional
    public void revokeAllUserTokens(User user) {
        refreshTokenRepository.deleteByUser(user);
    }
}

Auth Controller

The /register, /login, /refresh, and /logout endpoints are all public under /api/auth/**.

package com.vimleshpandey.demo.controller;

import com.vimleshpandey.demo.dto.*;
import com.vimleshpandey.demo.entity.*;
import com.vimleshpandey.demo.exception.TokenRefreshException;
import com.vimleshpandey.demo.repository.UserRepository;
import com.vimleshpandey.demo.service.*;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

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

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

    @PostMapping("/register")
    public ResponseEntity<AuthResponse> register(@RequestBody RegisterRequest request) {
        if (userRepository.findByUsername(request.getUsername()).isPresent()) {
            return ResponseEntity.badRequest().build();
        }
        User user = userRepository.save(User.builder()
                .username(request.getUsername())
                .password(passwordEncoder.encode(request.getPassword()))
                .role(Role.USER)
                .build());
        return ResponseEntity.ok(AuthResponse.builder()
                .accessToken(jwtService.generateAccessToken(user))
                .refreshToken(refreshTokenService.createRefreshToken(user.getUsername()).getToken())
                .build());
    }

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getUsername(), request.getPassword()));
        UserDetails userDetails = userDetailsService.loadUserByUsername(request.getUsername());
        return ResponseEntity.ok(AuthResponse.builder()
                .accessToken(jwtService.generateAccessToken(userDetails))
                .refreshToken(refreshTokenService.createRefreshToken(request.getUsername()).getToken())
                .build());
    }

    @PostMapping("/refresh")
    public ResponseEntity<AuthResponse> refreshToken(@RequestBody TokenRefreshRequest request) {
        RefreshToken existing = refreshTokenService.findByToken(request.getRefreshToken())
                .orElseThrow(() -> new TokenRefreshException("Refresh token not found."));
        refreshTokenService.verifyExpiration(existing);
        String username = existing.getUser().getUsername();
        return ResponseEntity.ok(AuthResponse.builder()
                .accessToken(jwtService.generateAccessToken(
                        userDetailsService.loadUserByUsername(username)))
                .refreshToken(refreshTokenService.createRefreshToken(username).getToken())
                .build());
    }

    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@AuthenticationPrincipal UserDetails userDetails) {
        refreshTokenService.revokeAllUserTokens((User) userDetails);
        return ResponseEntity.noContent().build();
    }
}

DTOs and Exception

// LoginRequest.java
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class LoginRequest { private String username; private String password; }

// RegisterRequest.java
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class RegisterRequest { private String username; private String password; }

// TokenRefreshRequest.java
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class TokenRefreshRequest { private String refreshToken; }

// AuthResponse.java
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class AuthResponse { private String accessToken; private String refreshToken; }
// TokenRefreshException.java
@ResponseStatus(HttpStatus.FORBIDDEN)
public class TokenRefreshException extends RuntimeException {
    public TokenRefreshException(String message) { super(message); }
}

Testing

Unit Test for JwtService

@Value injection does not work in pure Mockito unit tests. We use ReflectionTestUtils.setField to inject the values manually.

package com.vimleshpandey.demo.service;

import io.jsonwebtoken.JwtException;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.userdetails.*;
import org.springframework.test.util.ReflectionTestUtils;

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

@ExtendWith(MockitoExtension.class)
class JwtServiceTest {

    @InjectMocks
    private JwtService jwtService;

    @BeforeEach
    void setUp() {
        ReflectionTestUtils.setField(jwtService, "secretKey",
                "bWFrZS10aGlzLWEtMzItYnl0ZS1zZWNyZXQta2V5ISE=");
        ReflectionTestUtils.setField(jwtService, "jwtExpiration", 900000L);
    }

    @Test
    void generateToken_shouldContainCorrectUsername() {
        UserDetails user = User.withUsername("alice").password("pass").roles("USER").build();
        assertThat(jwtService.extractUsername(jwtService.generateAccessToken(user)))
                .isEqualTo("alice");
    }

    @Test
    void isTokenValid_shouldReturnTrue_forValidToken() {
        UserDetails user = User.withUsername("alice").password("pass").roles("USER").build();
        String token = jwtService.generateAccessToken(user);
        assertThat(jwtService.isTokenValid(token, user)).isTrue();
    }

    @Test
    void isTokenValid_shouldThrow_forTamperedToken() {
        UserDetails user = User.withUsername("alice").password("pass").roles("USER").build();
        String token = jwtService.generateAccessToken(user) + "tampered";
        assertThatThrownBy(() -> jwtService.isTokenValid(token, user))
                .isInstanceOf(JwtException.class);
    }

    @Test
    void isTokenValid_shouldReturnFalse_forExpiredToken() {
        ReflectionTestUtils.setField(jwtService, "jwtExpiration", 0L);
        UserDetails user = User.withUsername("bob").password("pass").roles("USER").build();
        assertThat(jwtService.isTokenValid(jwtService.generateAccessToken(user), user)).isFalse();
    }
}

Running the Application

# 1. Create the database
mysql -u root -p -e "CREATE DATABASE jwt_demo CHARACTER SET utf8mb4;"

# 2. Update application.yml with your credentials, then:
mvn spring-boot:run

Register a user:

curl -X POST http://localhost:8080/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"alice","password":"secret123"}'

Returns {"accessToken":"eyJ...","refreshToken":"550e8400-e29b-..."}.

Access a protected endpoint:

curl -H "Authorization: Bearer <accessToken>" \
  http://localhost:8080/api/users/me

Refresh when the access token expires:

curl -X POST http://localhost:8080/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"550e8400-e29b-..."}'

Logout (revokes the refresh token):

curl -X POST http://localhost:8080/api/auth/logout \
  -H "Authorization: Bearer <accessToken>"

Common Pitfalls and Security Tips

1. Algorithm Confusion Attacks

A classic attack on JWT systems: the attacker changes the alg header from RS256 to HS256 and re-signs the token using the server's public key as the HMAC secret. Naive verification code that reads the algorithm from the header accepts it.

JJWT 0.12.x eliminates this structurally: the algorithm is derived from the key type, not the header. A SecretKey only accepts symmetric algorithms. You cannot forge an HS256 token against a server that uses an RSA key pair, because the key types are incompatible.

2. Token Storage in the Browser

Avoid localStorage for JWTs. Any JavaScript on your page — including third-party analytics scripts — can read localStorage. If an attacker injects even one line of JavaScript via XSS, every stored token is compromised.

The 2025/2026 OWASP recommendation:

  • Access token: store in a JavaScript variable (lost on tab close, which is fine given the 15-minute TTL)
  • Refresh token: store in an HttpOnly; Secure; SameSite=Strict cookie — invisible to JavaScript, immune to XSS

3. Weak Secrets

The JWT secret must decode to at least 32 bytes for HS256. Strings like "mysecretkey" are dangerously short and guessable. Generate a proper random key using Jwts.SIG.HS256.key().build() and store it in a secrets manager.

4. Long-Lived Access Tokens

Every minute you add to the access token TTL extends the window in which a stolen token is valid. 15 minutes is a reasonable maximum. If you can tolerate the extra refresh calls, 5 minutes is better.

5. Validate All Claims

JJWT 0.12.x validates exp automatically on parse. For hardened deployments, add issuer and audience validation:

Jwts.parser()
    .verifyWith(signingKey)
    .requireIssuer("https://yourapp.com")
    .requireAudience("api")
    .build()
    .parseSignedClaims(token);

6. All Traffic Over HTTPS

TLS is not optional. A JWT transmitted over plain HTTP is readable by any observer on the network path. In production, enforce HTTPS at the load balancer or reverse proxy and add an HSTS header.

Conclusion

You now have a production-ready JWT authentication system built on Spring Boot 3.5.6 and Spring Security 6.5.4. The key architectural decisions are worth repeating:

  • JJWT 0.12.6 — modern API, algorithm confusion structurally impossible
  • No WebSecurityConfigurerAdapter — clean SecurityFilterChain bean only
  • Opaque UUID refresh tokens in MySQL — revocable instantly, no JWT tricks
  • Token rotation — each refresh invalidates the previous refresh token
  • 15-minute access token TTL — minimal blast radius on token theft

As next steps, consider adding:

  • Redis-backed token blacklist for instant access token revocation on logout

  • Rate limiting on the /login and /refresh endpoints to prevent brute-force attacks

  • Refresh token family tracking to detect refresh token reuse attacks (a rotated token presented again indicates a potential theft)

  • Secrets manager integration (HashiCorp Vault, AWS Secrets Manager) for the JWT secret

The complete, runnable project is available in the download below. Good luck!