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

Introduction

JSON Web Tokens (JWT) have become the de-facto standard for stateless authentication in modern REST APIs. A well-designed JWT system uses two types of tokens:

  • Access token — short-lived (15 minutes), sent with every API request in the Authorization: Bearer header, verified locally without a database hit.
  • Refresh token — long-lived (7 days), stored in the database, exchanged for a fresh access token when the old one expires.

This tutorial builds a complete, production-quality authentication service using Spring Boot 3.5, Spring Security 6, and JJWT 0.12.6 — the most recent stable release of the Java JWT library. We implement refresh-token rotation (each refresh invalidates the old token and issues a new pair), role-based access control, and a clean logout that revokes all active refresh tokens for a user.

Prerequisites

  • JDK 17 or later (JDK 21 LTS is recommended for production)
  • Maven 3.9+
  • Basic familiarity with Spring Boot and REST APIs
  • A REST client such as Postman or cURL for testing

Project Setup

Generate the skeleton at start.spring.io (Spring Web, Spring Security, Spring Data JPA, Validation, Lombok, H2) or use the pom.xml below.

pom.xml

The key additions are three JJWT artefacts. jjwt-api goes on the compile classpath; jjwt-impl and jjwt-jackson are runtime-only — you never reference internal implementation classes directly.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.3</version>
    </parent>
    <groupId>com.vimleshpandey</groupId>
    <artifactId>jwt-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <properties>
        <java.version>17</java.version>
        <jjwt.version>0.12.6</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>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>com.h2database</groupId>
            <artifactId>h2</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>
</project>

application.yml

spring:
  datasource:
    url: jdbc:h2:mem:jwtdb
    driver-class-name: org.h2.Driver
    username: sa
    password:
  h2:
    console:
      enabled: true
      path: /h2-console
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true

app:
  jwt:
    secret: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
    access-token-expiration: 900000      # 15 minutes in ms
    refresh-token-expiration: 604800000  # 7 days in ms

server:
  port: 8080

Security note: The secret value above is a placeholder. In production, load it from an environment variable or a secrets manager (HashiCorp Vault, AWS Secrets Manager). It must be at least 256 bits (32 bytes) for HMAC-SHA256.

Entity Layer

User Entity

User implements UserDetails so Spring Security can use it directly — no adapter class needed.

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")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class User implements UserDetails {

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

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

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String firstName;

    @Column(nullable = false)
    private String lastName;

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

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<RefreshToken> refreshTokens;

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

RefreshToken Entity

Storing refresh tokens in the database gives us the power to revoke them server-side — something you cannot do with pure stateless JWTs.

package com.vimleshpandey.demo.entity;

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

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

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

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

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

    @Column(nullable = false)
    private Instant expiryDate;

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

Repository Layer

// UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);
}

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

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

JWT Service

JJWT 0.12 introduced a significant API overhaul. The key changes from older versions:

Old API (< 0.12)New API (0.12+)
Jwts.parserBuilder()Jwts.parser()
.setSigningKey(key).verifyWith(secretKey)
.parseClaimsJws(token).parseSignedClaims(token)
.getBody().getPayload()
.setSubject(s).subject(s)
package com.vimleshpandey.demo.service;

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.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.function.Function;

@Service
public class JwtService {

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

    @Value("${app.jwt.access-token-expiration}")
    private long accessTokenExpiration;

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

    private String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) {
        return Jwts.builder()
                .claims(extraClaims)
                .subject(userDetails.getUsername())
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + accessTokenExpiration))
                .signWith(getSigningKey())
                .compact();
    }

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

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

    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(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
    }
}

Refresh Token Service

package com.vimleshpandey.demo.service;

import com.vimleshpandey.demo.entity.*;
import com.vimleshpandey.demo.exception.TokenRefreshException;
import com.vimleshpandey.demo.repository.RefreshTokenRepository;
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.UUID;

@Service
@RequiredArgsConstructor
public class RefreshTokenService {

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

    private final RefreshTokenRepository refreshTokenRepository;

    @Transactional
    public RefreshToken createRefreshToken(User user) {
        return refreshTokenRepository.save(RefreshToken.builder()
                .user(user)
                .token(UUID.randomUUID().toString())
                .expiryDate(Instant.now().plusMillis(refreshTokenExpiration))
                .revoked(false)
                .build());
    }

    public RefreshToken verifyExpiration(RefreshToken token) {
        if (token.isRevoked()) {
            throw new TokenRefreshException(token.getToken(),
                    "Token was revoked. Please log in again.");
        }
        if (token.getExpiryDate().isBefore(Instant.now())) {
            refreshTokenRepository.delete(token);
            throw new TokenRefreshException(token.getToken(),
                    "Token has expired. Please log in again.");
        }
        return token;
    }

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

Security Configuration

Spring Security 6 removed WebSecurityConfigurerAdapter. Configuration is done entirely through @Bean-annotated SecurityFilterChain methods — a cleaner, more composable approach.

package com.vimleshpandey.demo.config;

import com.vimleshpandey.demo.filter.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.*;
import org.springframework.security.authentication.*;
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)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/**", "/h2-console/**").permitAll()
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .headers(headers -> headers.frameOptions(f -> f.sameOrigin()))
            .authenticationProvider(authenticationProvider)
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}

ApplicationConfig — shared beans

@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {

    private final UserRepository userRepository;

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

JWT Authentication Filter

This OncePerRequestFilter intercepts every request, extracts the Bearer token, validates it, and sets the SecurityContext if valid.

package com.vimleshpandey.demo.filter;

import com.vimleshpandey.demo.service.JwtService;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
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);
        final String userEmail;
        try {
            userEmail = jwtService.extractUsername(jwt);
        } catch (Exception e) {
            filterChain.doFilter(request, response);
            return;
        }
        if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail);
            if (jwtService.isTokenValid(jwt, userDetails)) {
                var authToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }
        filterChain.doFilter(request, response);
    }
}

Auth Service — Register, Login, Refresh, Logout

The AuthService is the core of the implementation. Note how refreshToken() performs token rotation: it marks the incoming refresh token as revoked before issuing a new pair. If the same refresh token is used twice, the second call will receive a TokenRefreshException.

// Key methods — full source in the downloadable project

@Transactional
public AuthResponse register(RegisterRequest request) {
    if (userRepository.existsByEmail(request.getEmail()))
        throw new IllegalArgumentException("Email already registered");
    User user = User.builder()
            .email(request.getEmail())
            .password(passwordEncoder.encode(request.getPassword()))
            .firstName(request.getFirstName())
            .lastName(request.getLastName())
            .role(Role.USER)
            .build();
    userRepository.save(user);
    return buildAuthResponse(jwtService.generateAccessToken(user),
            refreshTokenService.createRefreshToken(user).getToken(), user);
}

@Transactional
public AuthResponse refreshToken(RefreshTokenRequest request) {
    RefreshToken rt = refreshTokenRepository.findByToken(request.getRefreshToken())
            .orElseThrow(() -> new TokenRefreshException(
                    request.getRefreshToken(), "Token not found"));
    refreshTokenService.verifyExpiration(rt);
    rt.setRevoked(true);                          // ← invalidate used token
    refreshTokenRepository.save(rt);
    User user = rt.getUser();
    return buildAuthResponse(
            jwtService.generateAccessToken(user),
            refreshTokenService.createRefreshToken(user).getToken(),
            user);
}

@Transactional
public void logout(String userEmail) {
    User user = userRepository.findByEmail(userEmail)
            .orElseThrow(() -> new IllegalArgumentException("User not found"));
    refreshTokenService.revokeAllUserTokens(user);
}

Auth Controller

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

    private final AuthService authService;

    @PostMapping("/register")
    public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest req) {
        return ResponseEntity.status(HttpStatus.CREATED).body(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 RefreshTokenRequest req) {
        return ResponseEntity.ok(authService.refreshToken(req));
    }

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

Testing

@SpringBootTest
@AutoConfigureMockMvc
class AuthIntegrationTest {

    @Autowired private MockMvc mockMvc;
    @Autowired private ObjectMapper objectMapper;

    @Test
    void registerAndRefreshFlow() throws Exception {
        RegisterRequest req = new RegisterRequest();
        req.setFirstName("Jane"); req.setLastName("Doe");
        req.setEmail("jane@example.com"); req.setPassword("securePass123");

        MvcResult result = mockMvc.perform(post("/api/v1/auth/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.accessToken").isNotEmpty())
                .andReturn();

        String refreshToken = objectMapper.readTree(
                result.getResponse().getContentAsString())
                .get("refreshToken").asText();

        mockMvc.perform(post("/api/v1/auth/refresh")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"refreshToken\": \"" + refreshToken + "\"}"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.accessToken").isNotEmpty());
    }

    @Test
    void badCredentialsShouldReturn401() throws Exception {
        LoginRequest req = new LoginRequest();
        req.setEmail("nobody@example.com"); req.setPassword("wrong");
        mockMvc.perform(post("/api/v1/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(req)))
                .andExpect(status().isUnauthorized());
    }
}

Running the Application

mvn spring-boot:run

Then test with cURL:

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

# Login
curl -s -X POST http://localhost:8080/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"jane@example.com","password":"securePass123"}' | jq .

# Access protected endpoint
curl -s http://localhost:8080/api/v1/me \
  -H "Authorization: Bearer <ACCESS_TOKEN>" | jq .

# Refresh tokens
curl -s -X POST http://localhost:8080/api/v1/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"<REFRESH_TOKEN>"}' | jq .

# Logout (revokes all refresh tokens)
curl -s -X POST http://localhost:8080/api/v1/auth/logout \
  -H "Authorization: Bearer <ACCESS_TOKEN>"

Visit the H2 console at http://localhost:8080/h2-console (JDBC URL: jdbc:h2:mem:jwtdb) to inspect the USERS and REFRESH_TOKENS tables live.

Common Pitfalls and Tips

1. Never store JWTs in localStorage.
localStorage is readable by any JavaScript on the page — a single XSS vulnerability is enough to drain every token. Use HttpOnly; Secure; SameSite=Strict cookies for web clients. Mobile apps can use the platform secure storage (iOS Keychain, Android Keystore).

2. Rotate refresh tokens on every use.
We do this above: each call to /refresh marks the incoming token as revoked and issues a completely new pair. If an attacker steals a refresh token, they get at most one use before the legitimate client's next refresh detects the discrepancy.

3. Your JWT secret must be at least 256 bits.
Keys.hmacShaKeyFor() will throw if the key is too short for the algorithm. A 32-character ASCII string is exactly 256 bits — the minimum for HMAC-SHA256.

4. Catch JwtException, not Exception.
In the filter, a narrow catch on JwtException (the JJWT base exception) is cleaner than catching Exception — it lets unexpected errors propagate as 500s rather than silently passing through as unauthenticated requests.

5. Add a jti claim for instant access-token blacklisting (optional).
Access tokens can't be revoked by default — they're valid until expiry. If you need immediate revocation (e.g., suspicious login detected), add a UUID jti claim when issuing the token and check it against a Redis blacklist in the filter before authenticating.

6. For production, swap H2 for PostgreSQL.
Add the postgresql driver dependency, change spring.datasource.url, and set ddl-auto: validate (with Flyway managing schema migrations).

Conclusion

We've built a complete, stateless authentication service with Spring Boot 3.5 and Spring Security 6 featuring:

  • JJWT 0.12.6's modern fluent API (no deprecated methods)
  • Refresh-token rotation — each use invalidates the old token and issues a fresh pair
  • Logout that actually works — all refresh tokens for a user are revoked server-side
  • Role-based access control with @PreAuthorize and Spring Security's method security
  • Clean Spring Security 6 configuration using SecurityFilterChain beans

The full project source is available as a downloadable ZIP above. Happy coding!