Refresh Tokens and Token Rotation

Why Refresh Tokens?

Short-lived access tokens (15–60 minutes) limit the damage if a token is stolen — it expires quickly. But forcing users to log in every hour is terrible UX.

Refresh tokens solve this: a long-lived token (7–30 days) stored securely lets the client silently obtain a new access token when the old one expires. The user stays logged in indefinitely without re-entering credentials.

sequenceDiagram
    participant Client
    participant AuthServer as Auth Endpoint
    participant API as Protected API

    Client->>AuthServer: POST /api/auth/login
    AuthServer-->>Client: {accessToken: exp 15min, refreshToken: exp 7d}

    Note over Client,API: Normal API usage (15 min)
    Client->>API: GET /api/data\nAuthorization: Bearer {accessToken}
    API-->>Client: 200 OK

    Note over Client,API: Access token expires
    Client->>API: GET /api/data\nAuthorization: Bearer {expiredToken}
    API-->>Client: 401 Unauthorized

    Note over Client,API: Silent token refresh
    Client->>AuthServer: POST /api/auth/refresh\n{refreshToken}
    AuthServer-->>Client: {newAccessToken, newRefreshToken}

    Client->>API: GET /api/data\nAuthorization: Bearer {newAccessToken}
    API-->>Client: 200 OK

Token Rotation: Every Refresh Issues a New Refresh Token

Token rotation is the critical security mechanism: every time a refresh token is used, the server issues a new refresh token and invalidates the old one.

flowchart LR
    RT1[Refresh Token 1\nactive] -->|used| Invalidated1[Refresh Token 1\ninvalidated]
    Invalidated1 --> RT2[Refresh Token 2\nactive]
    RT2 -->|used| Invalidated2[Refresh Token 2\ninvalidated]
    Invalidated2 --> RT3[Refresh Token 3\nactive]

Why rotation detects theft: If an attacker steals RT1 and uses it after the legitimate client already refreshed to RT2, the server sees that RT1 has already been used (invalidated). This is a reuse detection signal — someone has the old token. The server invalidates all tokens for that user, forcing a re-login.

sequenceDiagram
    participant Legitimate as Legitimate Client
    participant Attacker
    participant Server

    Note over Legitimate,Server: Normal rotation
    Legitimate->>Server: refresh with RT1
    Server-->>Legitimate: AT2 + RT2 (RT1 now invalid)

    Note over Attacker,Server: Attacker steals RT1 and tries to use it
    Attacker->>Server: refresh with RT1 (already used!)
    Server->>Server: RT1 already invalidated → THEFT DETECTED
    Server->>Server: Invalidate ALL tokens for this user
    Server-->>Attacker: 401 - token reuse detected
    Note over Legitimate,Server: Legitimate user forced to re-login

Database Schema

Refresh tokens must be stored in the database — unlike access tokens, they must be revocable:

CREATE TABLE refresh_tokens (
    id          BIGINT          NOT NULL AUTO_INCREMENT PRIMARY KEY,
    user_id     BIGINT          NOT NULL,
    token_hash  VARCHAR(64)     NOT NULL UNIQUE,  -- SHA-256 hash of the token
    expires_at  TIMESTAMP       NOT NULL,
    revoked     BOOLEAN         NOT NULL DEFAULT false,
    revoked_at  TIMESTAMP,
    created_at  TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    device_info VARCHAR(255),   -- optional: browser/device for UX
    FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);
CREATE INDEX idx_refresh_tokens_user  ON refresh_tokens(user_id);

Never store the raw token. Store a SHA-256 hash. If the token table is compromised, attackers cannot use the hashes.


RefreshToken Entity and Repository

@Entity
@Table(name = "refresh_tokens")
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(name = "token_hash", nullable = false, unique = true, length = 64)
    private String tokenHash;

    @Column(name = "expires_at", nullable = false)
    private LocalDateTime expiresAt;

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

    @Column(name = "revoked_at")
    private LocalDateTime revokedAt;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt = LocalDateTime.now();

    @Column(name = "device_info")
    private String deviceInfo;
}
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByTokenHash(String tokenHash);
    List<RefreshToken> findByUserIdAndRevokedFalse(Long userId);
    int revokeAllByUserId(Long userId); // via @Modifying query
}

RefreshTokenService

@Service
@RequiredArgsConstructor
public class RefreshTokenService {

    @Value("${app.jwt.refresh-expiration-ms:604800000}") // 7 days
    private long refreshTokenDurationMs;

    private final RefreshTokenRepository refreshTokenRepository;

    public String createRefreshToken(User user, String deviceInfo) {
        // Generate a cryptographically secure random token
        String rawToken = generateSecureToken();
        String tokenHash = hashToken(rawToken);

        RefreshToken refreshToken = new RefreshToken();
        refreshToken.setUser(user);
        refreshToken.setTokenHash(tokenHash);
        refreshToken.setExpiresAt(LocalDateTime.now().plusSeconds(refreshTokenDurationMs / 1000));
        refreshToken.setDeviceInfo(deviceInfo);

        refreshTokenRepository.save(refreshToken);

        return rawToken; // return the raw token to the client — never the hash
    }

    @Transactional
    public RefreshToken verifyAndRotate(String rawToken) {
        String tokenHash = hashToken(rawToken);

        RefreshToken token = refreshTokenRepository.findByTokenHash(tokenHash)
            .orElseThrow(() -> new InvalidRefreshTokenException("Refresh token not found"));

        if (token.isRevoked()) {
            // REUSE DETECTED — invalidate all tokens for this user
            revokeAllTokensForUser(token.getUser().getId());
            throw new RefreshTokenReusedException(
                "Refresh token reuse detected. All sessions invalidated."
            );
        }

        if (token.getExpiresAt().isBefore(LocalDateTime.now())) {
            token.revoke();
            refreshTokenRepository.save(token);
            throw new RefreshTokenExpiredException("Refresh token has expired");
        }

        // Rotate: revoke old token
        token.revoke();
        refreshTokenRepository.save(token);

        // Return the revoked token — caller will create a new one
        return token;
    }

    @Transactional
    public void revokeAllTokensForUser(Long userId) {
        List<RefreshToken> activeTokens =
            refreshTokenRepository.findByUserIdAndRevokedFalse(userId);
        activeTokens.forEach(t -> {
            t.setRevoked(true);
            t.setRevokedAt(LocalDateTime.now());
        });
        refreshTokenRepository.saveAll(activeTokens);
    }

    private String generateSecureToken() {
        byte[] randomBytes = new byte[32];
        new SecureRandom().nextBytes(randomBytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
    }

    private String hashToken(String rawToken) {
        return DigestUtils.sha256Hex(rawToken);
    }
}

JwtService: Refresh Token Generation

@Service
public class JwtService {

    @Value("${app.jwt.expiration-ms:900000}")         // 15 min
    private long jwtExpiration;

    @Value("${app.jwt.refresh-expiration-ms:604800000}") // 7 days
    private long refreshExpiration;

    public String generateToken(UserDetails userDetails) {
        return buildToken(Map.of(), userDetails.getUsername(), jwtExpiration);
    }

    public String generateRefreshToken(UserDetails userDetails) {
        // Refresh JWT — minimal claims, just subject
        return buildToken(Map.of("type", "refresh"), userDetails.getUsername(), refreshExpiration);
    }

    private String buildToken(Map<String, Object> claims, String subject, long expiration) {
        return Jwts.builder()
            .claims(claims)
            .subject(subject)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + expiration))
            .signWith(getSigningKey())
            .compact();
    }
    // ... extractUsername, isTokenValid, etc. from Article 8
}

Auth Controller: Login and Refresh Endpoints

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

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

    @PostMapping("/login")
    public ResponseEntity<AuthResponse> login(
        @RequestBody @Valid LoginRequest request,
        HttpServletRequest httpRequest
    ) {
        Authentication auth = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(request.email(), request.password())
        );

        AppUserDetails userDetails = (AppUserDetails) auth.getPrincipal();
        User user = userRepository.findById(userDetails.getId()).orElseThrow();

        String accessToken = jwtService.generateToken(userDetails);
        String rawRefreshToken = refreshTokenService.createRefreshToken(
            user,
            httpRequest.getHeader("User-Agent") // optional device info
        );

        return ResponseEntity.ok(new AuthResponse(
            accessToken, rawRefreshToken, userDetails.getUsername()
        ));
    }

    @PostMapping("/refresh")
    public ResponseEntity<AuthResponse> refresh(
        @RequestBody @Valid RefreshRequest request,
        HttpServletRequest httpRequest
    ) {
        // Verify, detect reuse, and rotate
        RefreshToken oldToken = refreshTokenService.verifyAndRotate(request.refreshToken());

        // Load user for new token generation
        UserDetails userDetails =
            userDetailsService.loadUserByUsername(oldToken.getUser().getUsername());

        // Issue new access token and refresh token
        String newAccessToken = jwtService.generateToken(userDetails);
        String newRawRefreshToken = refreshTokenService.createRefreshToken(
            oldToken.getUser(),
            httpRequest.getHeader("User-Agent")
        );

        return ResponseEntity.ok(new AuthResponse(
            newAccessToken, newRawRefreshToken, userDetails.getUsername()
        ));
    }

    @PostMapping("/logout")
    public ResponseEntity<Void> logout(
        @AuthenticationPrincipal UserDetails currentUser,
        @RequestBody(required = false) LogoutRequest request
    ) {
        if (request != null && request.refreshToken() != null) {
            // Revoke the specific refresh token
            refreshTokenService.revokeToken(request.refreshToken());
        } else {
            // Revoke all tokens (logout from all devices)
            AppUserDetails appUser = (AppUserDetails) currentUser;
            refreshTokenService.revokeAllTokensForUser(appUser.getId());
        }
        return ResponseEntity.ok().build();
    }
}

Token Storage on the Client

Where to store tokens has security implications:

flowchart TD
    Q[Where to store tokens?] --> WEB[Web Application]
    Q --> MOB[Mobile / Desktop App]

    WEB --> HC[HttpOnly Cookie\n✓ XSS-safe\n✗ CSRF risk — mitigate with SameSite=Strict]
    WEB --> MEM[JavaScript Memory\n✓ No XSS theft\n✗ Lost on page refresh]
    WEB --> LS[localStorage\n✗ XSS can steal token\n✗ Do not use for tokens]

    MOB --> KS[OS Keychain / Keystore\n✓ Secure storage\n✓ Best practice for mobile]
// Set refresh token as HttpOnly cookie (inaccessible to JavaScript)
@PostMapping("/login")
public ResponseEntity<AccessTokenResponse> login(@RequestBody LoginRequest request,
                                                  HttpServletResponse response) {
    // ... authenticate and generate tokens ...

    // Refresh token in HttpOnly cookie
    ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", rawRefreshToken)
        .httpOnly(true)       // not accessible via JavaScript
        .secure(true)         // HTTPS only
        .sameSite("Strict")   // CSRF protection for cookies
        .maxAge(Duration.ofDays(7))
        .path("/api/auth/refresh") // only sent to refresh endpoint
        .build();
    response.addHeader(HttpHeaders.SET_COOKIE, refreshCookie.toString());

    // Access token in response body (stored in memory by client)
    return ResponseEntity.ok(new AccessTokenResponse(accessToken));
}

// Read refresh token from cookie
@PostMapping("/refresh")
public ResponseEntity<AccessTokenResponse> refresh(
    @CookieValue("refreshToken") String refreshToken
) {
    // ... verify and rotate ...
}

Token Cleanup

Expired and revoked tokens accumulate in the database. Schedule cleanup:

@Component
@RequiredArgsConstructor
public class RefreshTokenCleanupJob {

    private final RefreshTokenRepository refreshTokenRepository;

    @Scheduled(cron = "0 0 3 * * *") // daily at 3am
    @Transactional
    public void deleteExpiredTokens() {
        int deleted = refreshTokenRepository.deleteByExpiresAtBefore(LocalDateTime.now());
        log.info("Cleaned up {} expired refresh tokens", deleted);
    }
}

Summary

  • Access tokens: short-lived (15 min–1 hour), stateless JWT, no revocation needed.
  • Refresh tokens: long-lived (7–30 days), stored in DB as hash, fully revocable.
  • Token rotation: every refresh issues a new refresh token and invalidates the old one.
  • Reuse detection: if an already-invalidated refresh token is presented, all sessions for that user are revoked — this signals token theft.
  • Store raw refresh tokens in HttpOnly + Secure + SameSite=Strict cookies for web apps; use OS keychain for mobile.
  • Never store raw tokens in the database — always hash with SHA-256.

Next: Article 10 covers LDAP authentication — integrating Spring Security with enterprise directory services.