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

Introduction

Security is the cornerstone of every production API. JSON Web Tokens (JWT) have become the industry standard for stateless authentication — they're self-contained, portable, and eliminate server-side session storage. But a single long-lived JWT is a liability: if one is intercepted, an attacker retains access until the token expires naturally. That's the problem the access token + refresh token pattern solves.

In this tutorial you'll build a complete, production-grade JWT authentication system using Spring Boot 3.3 and Spring Security 6, including:

  • User registration and login
  • Short-lived access tokens (15 minutes) as stateless JWTs
  • Long-lived refresh tokens (7 days) stored in the database as opaque UUIDs
  • Token rotation on every refresh — old token is revoked, a new pair is issued
  • Logout that immediately invalidates the refresh token

By the end you'll have a secure, deployable auth module you can drop into any Spring Boot project.

Prerequisites

  • Java 21 (LTS, recommended with Spring Boot 3.3)
  • Maven 3.9+
  • PostgreSQL running locally (or MySQL — see notes in application.yml)
  • Familiarity with Spring Boot REST APIs and basic Spring Security concepts

Project Setup

Generate a new project at start.spring.io with Web, Security, Data JPA, and Validation selected, then replace pom.xml with the following:

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

    <groupId>com.vimleshpandey</groupId>
    <artifactId>jwt-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>jwt-demo</name>
    <description>JWT Auth with Spring Boot 3 and Spring Security 6</description>

    <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>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- JJWT 0.12.x — note: impl and jackson are runtime-only -->
        <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>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>

Application Configuration

# src/main/resources/application.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/jwtdemo
    username: postgres
    password: postgres
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect

application:
  security:
    jwt:
      secret-key: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
      expiration: 900000            # 15 minutes
      refresh-token:
        expiration: 604800000       # 7 days

Security note: The secret-key is a placeholder. In production, generate a proper 256-bit base64 key (openssl rand -base64 32) and inject it via environment variables — never commit secrets to version control.

Domain Layer: User Entity and Role

Our User entity implements UserDetails directly, so Spring Security can use it without an adapter layer. We keep it simple: email as principal, BCrypt-hashed password, and a role enum.

Role.java

package com.vimleshpandey.demo.user;

public enum Role {
    USER,
    ADMIN
}

User.java

package com.vimleshpandey.demo.user;

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(unique = true, nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;

    private String firstName;
    private String lastName;

    @Enumerated(EnumType.STRING)
    private Role role;

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

    @Override
    public String getUsername() {
        return email;
    }

    @Override public boolean isAccountNonExpired()     { return true; }
    @Override public boolean isAccountNonLocked()      { return true; }
    @Override public boolean isCredentialsNonExpired() { return true; }
    @Override public boolean isEnabled()               { return true; }
}

UserRepository.java

package com.vimleshpandey.demo.user;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

Refresh Token Entity and Service

Refresh tokens are stored in the database as opaque UUIDs — not JWTs. This is a deliberate design choice: if a refresh token were also a JWT, you couldn't revoke it before its expiry without a blocklist. A database-backed UUID gives you full server-side control.

RefreshToken.java

package com.vimleshpandey.demo.token;

import com.vimleshpandey.demo.user.User;
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, length = 512)
    private String token;

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

    @Column(nullable = false)
    private Instant expiresAt;

    private boolean revoked;
}

RefreshTokenRepository.java

package com.vimleshpandey.demo.token;

import com.vimleshpandey.demo.user.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

import java.util.Optional;

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByToken(String token);

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

RefreshTokenService.java

package com.vimleshpandey.demo.token;

import com.vimleshpandey.demo.user.User;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
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("${application.security.jwt.refresh-token.expiration}")
    private long refreshExpiration;

    private final RefreshTokenRepository repository;

    @Transactional
    public RefreshToken createRefreshToken(User user) {
        // single active session per user — revoke all existing tokens first
        repository.revokeAllByUser(user);

        return repository.save(RefreshToken.builder()
                .user(user)
                .token(UUID.randomUUID().toString())
                .expiresAt(Instant.now().plusMillis(refreshExpiration))
                .revoked(false)
                .build());
    }

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

    public boolean isValid(RefreshToken token) {
        return !token.isRevoked() && token.getExpiresAt().isAfter(Instant.now());
    }

    @Transactional
    public void revokeToken(RefreshToken token) {
        token.setRevoked(true);
        repository.save(token);
    }

    @Transactional
    public void revokeAllForUser(User user) {
        repository.revokeAllByUser(user);
    }
}

JWT Service

The JwtService wraps all JJWT operations. Note the JJWT 0.12.x API — it differs meaningfully from the 0.11.x API that most older tutorials use.

package com.vimleshpandey.demo.security;

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

@Service
public class JwtService {

    @Value("${application.security.jwt.secret-key}")
    private String secretKey;

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

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

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

    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> claims, UserDetails userDetails, long expiration) {
        return Jwts.builder()
                .claims(claims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date())
                .expiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSignKey())
                .compact();
    }

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

    private Claims extractAllClaims(String token) {
        // JJWT 0.12.x: parseSignedClaims() replaces the removed parseClaimsJws()
        return Jwts.parser()
                .verifyWith(getSignKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

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

JWT Authentication Filter

The filter extends OncePerRequestFilter — Spring's guarantee that it runs exactly once per request, even in forward/include chains. It reads the Authorization: Bearer header, validates the JWT, and populates the SecurityContextHolder — making the request "authenticated" for the rest of the filter chain.

package com.vimleshpandey.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.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
@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 {

        // auth endpoints are public — skip token validation
        if (request.getServletPath().startsWith("/api/v1/auth")) {
            filterChain.doFilter(request, response);
            return;
        }

        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 userEmail = jwtService.extractUsername(jwt);

            if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail);

                if (jwtService.isTokenValid(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken =
                            new UsernamePasswordAuthenticationToken(
                                    userDetails, null, userDetails.getAuthorities());
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (Exception ignored) {
            // invalid or expired token — Spring Security will return 401/403 downstream
        }

        filterChain.doFilter(request, response);
    }
}

Security Configuration

Spring Security 6 replaces WebSecurityConfigurerAdapter (removed entirely) with bean-based configuration. You declare a SecurityFilterChain bean and wire up dependencies explicitly — no inheritance, no magic.

package com.vimleshpandey.demo.config;

import com.vimleshpandey.demo.security.JwtAuthenticationFilter;
import com.vimleshpandey.demo.user.UserRepository;
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.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
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
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final UserRepository userRepository;

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

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return username -> userRepository.findByEmail(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();
    }
}

Authentication Controller and Service

DTOs

// RegisterRequest.java
package com.vimleshpandey.demo.auth;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
public class RegisterRequest {
    @NotBlank private String firstName;
    @NotBlank private String lastName;
    @Email @NotBlank private String email;
    @NotBlank private String password;
}
// AuthRequest.java
package com.vimleshpandey.demo.auth;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
public class AuthRequest {
    @Email @NotBlank private String email;
    @NotBlank private String password;
}
// RefreshTokenRequest.java
package com.vimleshpandey.demo.auth;

import lombok.Data;

@Data
public class RefreshTokenRequest {
    private String refreshToken;
}
// AuthResponse.java
package com.vimleshpandey.demo.auth;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthResponse {
    private String accessToken;
    private String refreshToken;
}

AuthService

The service wires together the authentication manager, JWT generation, and refresh token management. The key design in refresh() is token rotation: every successful refresh revokes the old token and issues a fresh pair. This limits the blast radius if a refresh token is ever leaked.

package com.vimleshpandey.demo.auth;

import com.vimleshpandey.demo.security.JwtService;
import com.vimleshpandey.demo.token.RefreshToken;
import com.vimleshpandey.demo.token.RefreshTokenService;
import com.vimleshpandey.demo.user.Role;
import com.vimleshpandey.demo.user.User;
import com.vimleshpandey.demo.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class AuthService {

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

    public AuthResponse register(RegisterRequest request) {
        User user = User.builder()
                .firstName(request.getFirstName())
                .lastName(request.getLastName())
                .email(request.getEmail())
                .password(passwordEncoder.encode(request.getPassword()))
                .role(Role.USER)
                .build();
        userRepository.save(user);
        return issueTokenPair(user);
    }

    public AuthResponse authenticate(AuthRequest request) {
        // throws BadCredentialsException if email/password is wrong
        authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())
        );
        User user = userRepository.findByEmail(request.getEmail()).orElseThrow();
        return issueTokenPair(user);
    }

    public AuthResponse refresh(RefreshTokenRequest request) {
        RefreshToken stored = refreshTokenService.findByToken(request.getRefreshToken())
                .orElseThrow(() -> new IllegalArgumentException("Refresh token not found"));

        if (!refreshTokenService.isValid(stored)) {
            throw new IllegalArgumentException("Refresh token is expired or revoked");
        }

        User user = stored.getUser();
        // rotate: revoke old token, issue a fresh pair
        refreshTokenService.revokeToken(stored);
        return issueTokenPair(user);
    }

    public void logout(String userEmail) {
        User user = userRepository.findByEmail(userEmail).orElseThrow();
        refreshTokenService.revokeAllForUser(user);
    }

    private AuthResponse issueTokenPair(User user) {
        String accessToken = jwtService.generateAccessToken(user);
        RefreshToken refreshToken = refreshTokenService.createRefreshToken(user);
        return AuthResponse.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken.getToken())
                .build();
    }
}

AuthController

package com.vimleshpandey.demo.auth;

import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

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

    private final AuthService authService;

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

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

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

    // logout requires a valid access token — you prove who you are before revoking
    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@AuthenticationPrincipal UserDetails userDetails) {
        authService.logout(userDetails.getUsername());
        return ResponseEntity.noContent().build();
    }
}

Sample Protected Endpoint

package com.vimleshpandey.demo.demo;

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.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1")
public class DemoController {

    @GetMapping("/me")
    public ResponseEntity<String> whoAmI(@AuthenticationPrincipal UserDetails userDetails) {
        return ResponseEntity.ok("Hello, " + userDetails.getUsername());
    }
}

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

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

@SpringBootTest
class JwtServiceTest {

    @Autowired
    private JwtService jwtService;

    @Test
    void generatedAccessTokenShouldBeValidAndContainCorrectSubject() {
        UserDetails user = User.builder()
                .username("test@example.com")
                .password("irrelevant")
                .authorities(Collections.emptyList())
                .build();

        String token = jwtService.generateAccessToken(user);

        assertThat(token).isNotBlank();
        assertThat(jwtService.extractUsername(token)).isEqualTo("test@example.com");
        assertThat(jwtService.isTokenValid(token, user)).isTrue();
    }
}

Running the Application

1. Create the database:

CREATE DATABASE jwtdemo;

2. Update application.yml with your local PostgreSQL credentials.

3. Start the application:

mvn spring-boot:run

4. Register a user:

curl -X POST http://localhost:8080/api/v1/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"firstName":"Jane","lastName":"Doe","email":"jane@example.com","password":"secret123"}'

Response:

{
  "accessToken": "eyJhbGciOiJIUzI1NiJ9...",
  "refreshToken": "a3f8c2d1-7e4b-4a9f-bc23-1f2e3d4a5b6c"
}

5. Call a protected endpoint:

curl http://localhost:8080/api/v1/me \
  -H "Authorization: Bearer <access_token>"
# → "Hello, jane@example.com"

6. Refresh tokens when the access token expires:

curl -X POST http://localhost:8080/api/v1/auth/refresh \
  -H 'Content-Type: application/json' \
  -d '{"refreshToken":"a3f8c2d1-7e4b-4a9f-bc23-1f2e3d4a5b6c"}'

7. Logout:

curl -X POST http://localhost:8080/api/v1/auth/logout \
  -H "Authorization: Bearer <access_token>"
# → 204 No Content

After logout, the refresh token is revoked — a subsequent /refresh call returns 500 (you can add a proper 401 handler via @ControllerAdvice).

Common Pitfalls and Tips

1. Using the wrong JJWT parser method after upgrading to 0.12.x
parseClaimsJws() was removed in JJWT 0.12.x. Replace it with parseSignedClaims(). This is the most common compile error when upgrading from 0.11.x tutorials.

2. Secret key too short — WeakKeyException at startup
HMAC-SHA256 requires a key of at least 256 bits (32 bytes). Strings shorter than 32 characters will throw WeakKeyException. Generate a proper key:

openssl rand -base64 32

3. Circular bean dependency in SecurityConfig
If SecurityConfig injects UserDetailsService as a bean, and UserDetailsService loads via UserRepository, but UserRepository somehow triggers security — you get a circular dependency. The fix used above is to declare userDetailsService() directly inside SecurityConfig referencing UserRepository (injected as a constructor arg), never as a separate component.

4. CSRF is disabled — intentionally for stateless APIs
AbstractHttpConfigurer::disable on CSRF is correct for REST APIs consumed by SPAs or mobile clients. If you ever add Thymeleaf server-rendered forms, re-enable CSRF for those URL patterns only using a separate SecurityFilterChain bean.

5. Don't use JWTs as refresh tokens
An opaque UUID stored in the database (our approach) means you can revoke it instantly. A JWT refresh token cannot be revoked before its expiry without a blocklist — defeating the purpose of logout. Keep refresh tokens opaque.

Conclusion

You've built a production-ready JWT authentication system from scratch with Spring Boot 3.3 and Spring Security 6. The key design decisions:

  • Stateless access tokens (JWTs, 15 minutes) carry no server state
  • Opaque refresh tokens (UUIDs, database-backed, 7 days) give you instant revocation
  • Token rotation on every refresh — limits exposure if a token is ever leaked
  • Immediate logout — refresh token revoked, access token expires naturally within 15 minutes

From here, natural extensions include:

  • @PreAuthorize method security for fine-grained RBAC

  • Redis-based access token blocklist for immediate access token revocation on logout

  • OAuth2 Social Login via Spring Security OAuth2 Client

  • Rate limiting on /login and /register to prevent brute-force attacks

The full downloadable project is attached below — clone it, swap in your database credentials, and you're running in minutes.