Spring Security: JWT Access & Refresh Token Pattern

The access + refresh token pattern solves the tension between security and usability. Short-lived access tokens (15 min) limit exposure if stolen; long-lived refresh tokens (7 days, stored securely) let the client obtain new access tokens without requiring the user to log in again. Spring Security handles the filter-chain plumbing; your service manages token lifecycle.

Contents

Dependency (JJWT)

Add the following to your pom.xml (Maven) or build.gradle (Gradle). Spring Boot manages compatible versions automatically when you use the Spring Boot BOM.

XML
<!-- JJWT — Java JWT library -->
<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>
Properties
# application.properties
app.jwt.secret=your-256-bit-base64-secret-key-here
app.jwt.access-token-expiry-ms=900000      # 15 minutes
app.jwt.refresh-token-expiry-ms=604800000  # 7 days

JwtService: Generate & Validate Tokens

The class below shows the implementation. Key points are highlighted in the inline comments.

Java
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.util.Base64;
import java.util.Date;
import java.util.Map;

@Service
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(Base64.getDecoder().decode(secret));
        this.accessTokenExpiryMs = accessTokenExpiryMs;
    }

    public String generateAccessToken(AppUserDetails user) {
        return Jwts.builder()
            .subject(user.getUsername())
            .claim("userId", user.getId())
            .claim("roles", user.getAuthorities().stream()
                .map(a -> a.getAuthority()).toList())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + accessTokenExpiryMs))
            .signWith(signingKey)
            .compact();
    }

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

    public String extractUsername(String token) {
        return validateAndParse(token).getSubject();
    }

    public boolean isTokenExpired(String token) {
        return validateAndParse(token).getExpiration().before(new Date());
    }
}

Refresh Token Entity & Repository

Store refresh tokens in the database so they can be revoked. Never store them only in a cookie or memory: that prevents logout and token invalidation.

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

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

    @Id @GeneratedValue(strategy = GenerationType.UUID)
    private String id;           // This IS the token value (UUID)

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

    @Column(nullable = false)
    private Instant expiresAt;

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

    // getters / setters
}
Java
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, String> {
    Optional<RefreshToken> findByIdAndRevokedFalse(String id);

    @Modifying
    @Query("UPDATE RefreshToken r SET r.revoked = true WHERE r.user.id = :userId")
    void revokeAllForUser(Long userId);
}

JWT Authentication Filter

The filter runs on every request, extracts the Bearer token from the Authorization header, validates it, and sets the SecurityContext. It extends OncePerRequestFilter to guarantee single execution per request.

Java
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.*;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final AppUserDetailsService userDetailsService;

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

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws java.io.IOException, jakarta.servlet.ServletException {

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

        String token = authHeader.substring(7);
        String username;
        try {
            username = jwtService.extractUsername(token);
        } catch (JwtException e) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
            return;
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails user = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken authToken =
                new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
            authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authToken);
        }

        chain.doFilter(request, response);
    }
}

Login & Refresh Endpoints

The class below shows the implementation. Key points are highlighted in the inline comments.

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

    private final AuthenticationManager authManager;
    private final JwtService jwtService;
    private final RefreshTokenService refreshTokenService;

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest req) {
        authManager.authenticate(
            new UsernamePasswordAuthenticationToken(req.username(), req.password()));

        AppUserDetails user = (AppUserDetails)
            userDetailsService.loadUserByUsername(req.username());

        String accessToken  = jwtService.generateAccessToken(user);
        String refreshToken = refreshTokenService.createRefreshToken(user.getId()).getId();

        return ResponseEntity.ok(new AuthResponse(accessToken, refreshToken));
    }

    @PostMapping("/refresh")
    public ResponseEntity<AuthResponse> refresh(@RequestBody RefreshRequest req) {
        RefreshToken rt = refreshTokenService.validateAndRotate(req.refreshToken());

        AppUserDetails user = (AppUserDetails)
            userDetailsService.loadUserByUsername(rt.getUser().getUsername());

        String newAccessToken  = jwtService.generateAccessToken(user);
        String newRefreshToken = rt.getId();   // rotated token id returned by validateAndRotate

        return ResponseEntity.ok(new AuthResponse(newAccessToken, newRefreshToken));
    }

    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@AuthenticationPrincipal AppUserDetails user) {
        refreshTokenService.revokeAll(user.getId());
        SecurityContextHolder.clearContext();
        return ResponseEntity.noContent().build();
    }
}

Token Rotation & Revocation

Token rotation replaces the old refresh token with a new one on every use, limiting the damage if a refresh token is stolen: the attacker's copy is invalidated after first use.

Java
@Service
@Transactional
public class RefreshTokenService {

    private final RefreshTokenRepository repo;
    private final UserRepository userRepo;

    @Value("${app.jwt.refresh-token-expiry-ms}") private long expiryMs;

    public RefreshToken createRefreshToken(Long userId) {
        RefreshToken rt = new RefreshToken();
        rt.setUser(userRepo.getReferenceById(userId));
        rt.setExpiresAt(Instant.now().plusMillis(expiryMs));
        return repo.save(rt);
    }

    public RefreshToken validateAndRotate(String tokenId) {
        RefreshToken rt = repo.findByIdAndRevokedFalse(tokenId)
            .orElseThrow(() -> new InvalidTokenException("Refresh token not found or revoked"));

        if (rt.getExpiresAt().isBefore(Instant.now())) {
            rt.setRevoked(true);
            throw new InvalidTokenException("Refresh token expired");
        }

        // Revoke old token and issue a new one (rotation)
        rt.setRevoked(true);
        repo.save(rt);
        return createRefreshToken(rt.getUser().getId());
    }

    public void revokeAll(Long userId) {
        repo.revokeAllForUser(userId);
    }
}

SecurityFilterChain Configuration

The class below shows the implementation. Key points are highlighted in the inline comments.

Java
@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .cors(cors -> cors.configurationSource(corsSource()))
            .sessionManagement(sm -> sm
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception {
        return cfg.getAuthenticationManager();
    }
}
Store refresh tokens in an HttpOnly, Secure, SameSite=Strict cookie (not in localStorage) to prevent XSS theft. Access tokens can be kept in memory (JavaScript variable) since they are short-lived.