You’ve implemented JWT authentication, but if you make the access token’s expiration too short, users have to log in again frequently, and if you make it too long, the risk of theft increases. A classic dilemma.

This problem can be solved with refresh tokens. In this article, I’ll introduce an implementation pattern that combines Spring Boot and Redis, covering rotation, reuse detection, and revocation. I’ll proceed on the assumption that access token issuance is already covered in How to Implement JWT Authentication with Spring Security.

Why Separate the Two Tokens

Access tokens and refresh tokens have clearly different responsibilities.

  • Access tokens are attached to every API call. Short-lived (5–15 minutes) with stateless validation.
  • Refresh tokens are used only to reissue access tokens. Long-lived (days to weeks) with server-side state.

By keeping access tokens short-lived, you can limit the damage window if one leaks. Refresh tokens, on the other hand, are stored server-side in Redis, so you can revoke them at any time. This division of roles is the key.

Redis or RDB for Storage

In this article, I’ll use Redis. The reasons are simple: TTL (automatic expiration) is a standard feature, it’s fast, and it scales out easily.

RDB has its merits too — it’s easy to reuse existing infrastructure, and it’s suited for transactional consistency and audit logs. That said, refresh tokens are read and written every time the user performs an action, so Redis is easier from an operational standpoint. For Redis setup, refer to Spring Boot and Redis Integration Guide.

Note that we’ll store hashed values, not plaintext. This ensures that even if Redis is somehow leaked, the tokens themselves can’t be reconstructed.

Setting Up Dependencies

We’ll use Lombok’s @RequiredArgsConstructor in the code below, so include it in the dependencies. When signing with HS256, the secret key must be at least 256 bits (32 bytes); anything shorter will cause Keys.hmacShaKeyFor to throw an exception. Set the JWT_SECRET environment variable to a random value of at least 32 characters.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

Externalizing the expiration values makes it easy to adjust them per environment.

app:
  jwt:
    secret: ${JWT_SECRET}
    access-token-ttl: PT15M
    refresh-token-ttl: P14D
spring:
  data:
    redis:
      host: localhost
      port: 6379

JwtTokenProvider

This is the core of token generation and validation. The key point is to always include a jti (JWT ID), which serves as the identifier for later revocation management. Don’t forget to distinguish between access and refresh tokens using the type claim either.

@Component
public class JwtTokenProvider {
    private final SecretKey key;
    private final Duration accessTtl;
    private final Duration refreshTtl;

    public JwtTokenProvider(@Value("${app.jwt.secret}") String secret,
                            @Value("${app.jwt.access-token-ttl}") Duration accessTtl,
                            @Value("${app.jwt.refresh-token-ttl}") Duration refreshTtl) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.accessTtl = accessTtl;
        this.refreshTtl = refreshTtl;
    }

    public String createAccess(String userId) {
        return build(userId, UUID.randomUUID().toString(), "access", accessTtl);
    }

    public String createRefresh(String userId, String jti) {
        return build(userId, jti, "refresh", refreshTtl);
    }

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

    private String build(String userId, String jti, String type, Duration ttl) {
        Instant now = Instant.now();
        return Jwts.builder()
                .subject(userId).id(jti)
                .claim("type", type)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plus(ttl)))
                .signWith(key).compact();
    }
}

I’ll also prepare a hashing utility. To avoid storing plaintext in Redis, we route both save and verify operations through this.

public static String sha256(String input) {
    try {
        MessageDigest md = MessageDigest.getInstance("SHA-256");
        byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(hash);
    } catch (NoSuchAlgorithmException e) {
        throw new IllegalStateException(e);
    }
}

RefreshTokenStore

This stores hashed refresh tokens in Redis. Designing the key as refresh:{userId}:{jti} makes bulk deletion per user easier.

For reuse detection, instead of immediately deleting the rotated old jti, we keep it under a separate key used:{userId}:{jti} as a “used marker.” The design treats a re-presentation of the same jti as a possible theft.

@Component
@RequiredArgsConstructor
public class RefreshTokenStore {
    private final StringRedisTemplate redis;

    public void save(String userId, String jti, String tokenHash, Duration ttl) {
        redis.opsForValue().set(activeKey(userId, jti), tokenHash, ttl);
    }

    public boolean isActive(String userId, String jti, String tokenHash) {
        String stored = redis.opsForValue().get(activeKey(userId, jti));
        return tokenHash.equals(stored);
    }

    public boolean isUsed(String userId, String jti) {
        return Boolean.TRUE.equals(redis.hasKey(usedKey(userId, jti)));
    }

    public void markUsed(String userId, String jti, Duration ttl) {
        redis.delete(activeKey(userId, jti));
        redis.opsForValue().set(usedKey(userId, jti), "1", ttl);
    }

    public void revokeAll(String userId) {
        String pattern = "refresh:" + userId + ":*";
        ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build();
        try (Cursor<String> cursor = redis.scan(options)) {
            cursor.forEachRemaining(redis::delete);
        }
        // used:{userId}:* should be cleaned up similarly with SCAN
    }

    private String activeKey(String userId, String jti) {
        return "refresh:" + userId + ":" + jti;
    }

    private String usedKey(String userId, String jti) {
        return "used:" + userId + ":" + jti;
    }
}

In production, the KEYS command blocks Redis, so I’m using SCAN here. Although revokeAll for theft detection is infrequent, blocking operations affect the entire service, so it’s safer to avoid them.

Implementing /auth/refresh

This is the most important part of refresh tokens. Validation → issuing new tokens → marking the old token as used — all done as a single set.

Define the request and response concisely with record.

public record RefreshRequest(String refreshToken) {}
public record TokenResponse(String accessToken, String refreshToken) {}
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
    private final JwtTokenProvider tokens;
    private final RefreshTokenStore store;
    private static final Duration REFRESH_TTL = Duration.ofDays(14);

    @PostMapping("/refresh")
    public TokenResponse refresh(@RequestBody RefreshRequest req) {
        Claims claims = tokens.parse(req.refreshToken());
        if (!"refresh".equals(claims.get("type", String.class))) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "wrong token type");
        }
        String userId = claims.getSubject();
        String oldJti = claims.getId();
        String hash = sha256(req.refreshToken());

        if (store.isUsed(userId, oldJti)) {
            store.revokeAll(userId);
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "reuse detected");
        }
        if (!store.isActive(userId, oldJti, hash)) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "invalid token");
        }

        store.markUsed(userId, oldJti, REFRESH_TTL);
        String newJti = UUID.randomUUID().toString();
        String newRefresh = tokens.createRefresh(userId, newJti);
        store.save(userId, newJti, sha256(newRefresh), REFRESH_TTL);
        return new TokenResponse(tokens.createAccess(userId), newRefresh);
    }
}

There are two key points. The first is validating the type claim, which prevents the vulnerability of mistakenly using an access token as a refresh token. The second is checking the used marker — even if the signature is valid, if a previously rotated old jti is presented again, we treat it as theft and run revokeAll.

If you want deletion and storage to be atomic, you can use a Lua script via RedisTemplate#execute. Since we’re using the used-marker approach here, even if a race condition occurs, one side will hit “already used” and the design fails safely.

Access Token Blacklist

Refresh tokens are immediately invalidated by removing them from the store, but since access tokens use stateless validation, they remain valid for their remaining lifetime.

If you want immediate revocation on logout or password change, register the jti in a blacklist. Setting the TTL to match the access token’s remaining lifetime means it disappears automatically — easy to operate.

public void blacklist(String jti, Duration remaining) {
    redis.opsForValue().set("bl:" + jti, "1", remaining);
}

public boolean isBlacklisted(String jti) {
    return Boolean.TRUE.equals(redis.hasKey("bl:" + jti));
}

SecurityFilterChain and Authentication Filter

Sessions are STATELESS, and since this article assumes a Bearer token in the Authorization header, we disable CSRF (if you switch to a design that stores refresh tokens in cookies, you’ll need to enable CSRF outside of /auth/** and combine SameSite cookies with CSRF tokens).

@Bean
SecurityFilterChain chain(HttpSecurity http, JwtAuthenticationFilter jwt) throws Exception {
    return http
        .csrf(c -> c.disable())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(a -> a
            .requestMatchers("/auth/**").permitAll()
            .anyRequest().authenticated())
        .addFilterBefore(jwt, UsernamePasswordAuthenticationFilter.class)
        .build();
}

Inside the authentication filter, in addition to signature verification, we also check the blacklist.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtTokenProvider tokens;
    private final TokenBlacklist blacklist;

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws ServletException, IOException {
        String header = req.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            try {
                Claims claims = tokens.parse(header.substring(7));
                if ("access".equals(claims.get("type", String.class))
                        && !blacklist.isBlacklisted(claims.getId())) {
                    Authentication auth = new UsernamePasswordAuthenticationToken(
                            claims.getSubject(), null, List.of());
                    SecurityContextHolder.getContext().setAuthentication(auth);
                }
            } catch (JwtException ignored) {
                // On failure, proceed unauthenticated
            }
        }
        chain.doFilter(req, res);
    }
}

For adding method-level authorization, reading How to Implement Method Security with @PreAuthorize alongside this article will deepen your understanding.

Client-Side Storage

For SPAs, the first choice for storing refresh tokens is HttpOnly + Secure + SameSite cookies. Storing them in localStorage makes them trivially stealable via XSS.

When using cookies, always set SameSite=Lax or Strict as a CSRF measure, and the safest approach is to separate the refresh endpoint to a different path and combine it with CSRF tokens. For mobile apps, use OS-side secure storage (Keychain on iOS, EncryptedSharedPreferences on Android).

Verifying the Behavior

# Login
curl -X POST localhost:8080/auth/login -d '{"id":"u1","pw":"..."}'

# Refresh (success case)
curl -X POST localhost:8080/auth/refresh \
  -H 'Content-Type: application/json' \
  -d '{"refreshToken":"<token>"}'

# Sending the same token a second time triggers reuse detection (401),
# and all of that user's refresh tokens are revoked

With MockMvc, covering at least these three patterns gives peace of mind.

@Test
void refresh_success_rotatesToken() throws Exception {
    mockMvc.perform(post("/auth/refresh").contentType(APPLICATION_JSON)
            .content("{\"refreshToken\":\"" + validToken + "\"}"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$.refreshToken").value(not(validToken)));
}

@Test
void refresh_reuse_revokesAllSessions() throws Exception {
    // First call succeeds
    mockMvc.perform(post("/auth/refresh").contentType(APPLICATION_JSON)
            .content("{\"refreshToken\":\"" + validToken + "\"}"));
    // Second call with the same token → 401 + revokeAll
    mockMvc.perform(post("/auth/refresh").contentType(APPLICATION_JSON)
            .content("{\"refreshToken\":\"" + validToken + "\"}"))
        .andExpect(status().isUnauthorized());
}

Summary

The three things to nail down when implementing refresh tokens are rotation, reuse detection, and hashed storage. Leveraging Redis’s TTL keeps revocation management simple.

Before going to production, also verify the clock skew margin (a few dozen seconds or so) and refresh race conditions when multiple requests fire in a short window. A realistic design balances UX with security — either implementing client-side retry on 401, or giving the old token a few seconds of grace period.