Home / Spring Boot / Security / JWT Access and Refresh Token Flow (Complete Design)

JWT Access and Refresh Token Flow (Complete Design)

Short-lived access tokens are safe but annoying, long-lived tokens are convenient but dangerous. The standard solution is a short access token plus a long-lived refresh token that is stored (hashed) on the server, rotated at every use and revocable on logout.

The flow

LOGIN     POST /auth/login {user, password}
            ◄── access token (JWT, 15 min)  +  refresh token (random string, 7 days, HttpOnly cookie)
API CALL  GET /api/orders   Authorization: Bearer <access token>       ─► 200
EXPIRED   GET /api/orders   (access token expired)                     ─► 401
REFRESH   POST /auth/refresh  (cookie carries the refresh token)
            server: hash(token) ─► find in DB ─► valid and not used? ─► mark it USED
            ◄── NEW access token + NEW refresh token   (rotation)
REUSE     an old (used) refresh token is presented again  ─► theft suspected ─► revoke ALL tokens of that user
LOGOUT    POST /auth/logout ─► delete/revoke the refresh token; the access token just expires
TokenLifetimeFormatStored where
Access token5 to 15 minutesSigned JWTClient memory. Not stored on the server
Refresh tokenDaysRandom opaque string (32 bytes)Client: HttpOnly Secure cookie. Server: only the SHA-256 HASH in the database

Entity and repository

@Entity
@Table(name = "refresh_tokens", indexes = @Index(columnList = "tokenHash", unique = true))
public class RefreshToken {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
    @Column(nullable = false, unique = true) private String tokenHash;
    @Column(nullable = false) private String username;
    @Column(nullable = false) private Instant expiresAt;
    private boolean used;
    private boolean revoked;

    protected RefreshToken() { }
    public RefreshToken(String tokenHash, String username, Instant expiresAt) {
        this.tokenHash = tokenHash; this.username = username; this.expiresAt = expiresAt;
    }
    public String getUsername() { return username; }
    public boolean isUsed() { return used; }
    public boolean isRevoked() { return revoked; }
    public boolean isExpired() { return expiresAt.isBefore(Instant.now()); }
    public void markUsed() { this.used = true; }
    public void revoke() { this.revoked = true; }
}

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
    Optional<RefreshToken> findByTokenHash(String tokenHash);
    @Modifying @Transactional
    @Query("update RefreshToken t set t.revoked = true where t.username = :username")
    void revokeAllByUsername(@Param("username") String username);
}

Refresh token service

@Service
public class RefreshTokenService {

    private static final SecureRandom RANDOM = new SecureRandom();
    private final RefreshTokenRepository repository;
    private final Duration lifetime = Duration.ofDays(7);

    public RefreshTokenService(RefreshTokenRepository repository) { this.repository = repository; }

    @Transactional
    public String create(String username) {
        byte[] bytes = new byte[32];
        RANDOM.nextBytes(bytes);
        String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);      // sent to the client ONCE
        repository.save(new RefreshToken(hash(token), username, Instant.now().plus(lifetime)));
        return token;
    }

    /** Validates the token, marks it used and returns the username. Throws if invalid. */
    @Transactional
    public String consume(String token) {
        RefreshToken stored = repository.findByTokenHash(hash(token))
                .orElseThrow(() -> new BadCredentialsException("Unknown refresh token"));
        if (stored.isUsed()) {                                     // reuse = probably stolen
            repository.revokeAllByUsername(stored.getUsername());
            throw new BadCredentialsException("Refresh token reuse detected");
        }
        if (stored.isRevoked() || stored.isExpired()) throw new BadCredentialsException("Refresh token expired");
        stored.markUsed();
        return stored.getUsername();
    }

    @Transactional
    public void revoke(String token) {
        repository.findByTokenHash(hash(token)).ifPresent(RefreshToken::revoke);
    }

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

Auth controller (uses the TokenService from the JWT page)

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

    private final AuthenticationManager authManager;
    private final TokenService accessTokens;          // creates the signed JWT
    private final RefreshTokenService refreshTokens;
    private final UserDetailsService users;

    public AuthController(AuthenticationManager authManager, TokenService accessTokens,
                          RefreshTokenService refreshTokens, UserDetailsService users) {
        this.authManager = authManager; this.accessTokens = accessTokens;
        this.refreshTokens = refreshTokens; this.users = users;
    }

    @PostMapping("/login")
    public ResponseEntity<Map<String, String>> login(@RequestBody LoginRequest request) {
        Authentication auth = authManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.username(), request.password()));
        return tokensFor(auth.getName(), roles(auth.getAuthorities()));
    }

    @PostMapping("/refresh")
    public ResponseEntity<Map<String, String>> refresh(@CookieValue(name = "refresh_token", required = false) String token) {
        if (token == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        String username = refreshTokens.consume(token);                       // rotation: this token is now used
        UserDetails user = users.loadUserByUsername(username);
        return tokensFor(username, roles(user.getAuthorities()));
    }

    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@CookieValue(name = "refresh_token", required = false) String token) {
        if (token != null) refreshTokens.revoke(token);
        ResponseCookie clear = ResponseCookie.from("refresh_token", "").path("/auth").maxAge(0).build();
        return ResponseEntity.noContent().header(HttpHeaders.SET_COOKIE, clear.toString()).build();
    }

    private ResponseEntity<Map<String, String>> tokensFor(String username, List<String> roles) {
        String access = accessTokens.createToken(username, roles);
        String refresh = refreshTokens.create(username);
        ResponseCookie cookie = ResponseCookie.from("refresh_token", refresh)
                .httpOnly(true).secure(true).sameSite("Strict").path("/auth").maxAge(Duration.ofDays(7)).build();
        return ResponseEntity.ok().header(HttpHeaders.SET_COOKIE, cookie.toString()).body(Map.of("accessToken", access));
    }

    private static List<String> roles(Collection<? extends GrantedAuthority> a) {
        return a.stream().map(x -> x.getAuthority().replace("ROLE_", "")).toList();
    }
}

Design notes

Common mistake: Storing the refresh token in localStorage and never rotating it. Any script injection can steal a long-lived credential. Use an HttpOnly cookie, rotate on every use, and store only a hash on the server.

Interview questions

Why not simply make the access token last 30 days?

A stolen token would give an attacker access for 30 days and you cannot revoke a JWT. Short access tokens limit the damage.

What is refresh token rotation?

Every refresh call returns a new refresh token and invalidates the old one, so reuse of an old token reveals theft.

Where should the access token live in a browser app?

In memory (a JavaScript variable). After a page reload the app gets a new one using the refresh cookie.