Refresh Endpoint and Rotation

The RefreshToken Entity

@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 64)
    private String tokenHash;            // SHA-256 hex of the raw token

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private User user;

    @Column(nullable = false)
    private String family;               // all rotations of one login share a family id

    private Instant expiresAt;
    private boolean used;
    private boolean revoked;
    // constructors, getters, setters...
}

RefreshTokenService

@Service
public class RefreshTokenService {

    private static final Duration TTL = Duration.ofDays(7);
    private final RefreshTokenRepository repo;
    private final SecureRandom random = new SecureRandom();

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

    public record Issued(String rawToken, User user) {}

    @Transactional
    public Issued issue(User user, String family) {
        byte[] bytes = new byte[32];
        random.nextBytes(bytes);
        String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
        repo.save(new RefreshToken(sha256(raw), user, family, Instant.now().plus(TTL)));
        return new Issued(raw, user);
    }

    /** Uses up the old token and returns a new one (rotation). */
    @Transactional
    public Issued rotate(String raw) {
        RefreshToken old = repo.findByTokenHash(sha256(raw))
                .orElseThrow(() -> new BadCredentialsException("Unknown refresh token"));
        if (old.isUsed() || old.isRevoked()) {
            // reuse of an old token = probably stolen: kill the whole family
            repo.revokeFamily(old.getFamily());
            throw new BadCredentialsException("Refresh token reuse detected");
        }
        if (old.getExpiresAt().isBefore(Instant.now())) {
            throw new BadCredentialsException("Refresh token expired");
        }
        old.setUsed(true);
        return issue(old.getUser(), old.getFamily());
    }

    @Transactional
    public void revoke(String raw) {
        repo.findByTokenHash(sha256(raw)).ifPresent(t -> repo.revokeFamily(t.getFamily()));
    }

    static String sha256(String s) {
        try {
            return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
                    .digest(s.getBytes(StandardCharsets.UTF_8)));
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }
}

The Endpoints

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

    private static final String COOKIE = "shopease_rt";
    // ... constructor with authenticationManager, jwtService, refreshTokens, users, userDetailsService

    @PostMapping("/login")
    public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest req) {
        Authentication auth = authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(req.email(), req.password()));
        User user = users.findByEmail(auth.getName()).orElseThrow();
        var issued = refreshTokens.issue(user, UUID.randomUUID().toString());
        return withCookie(issued.rawToken(), body((UserDetails) auth.getPrincipal()));
    }

    @PostMapping("/refresh")
    public ResponseEntity<LoginResponse> refresh(@CookieValue(name = COOKIE, required = false) String raw) {
        if (raw == null) throw new BadCredentialsException("No refresh token");
        var issued = refreshTokens.rotate(raw);
        UserDetails details = userDetailsService.loadUserByUsername(issued.user().getEmail());
        return withCookie(issued.rawToken(), body(details));     // fresh roles every refresh
    }

    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@CookieValue(name = COOKIE, required = false) String raw) {
        if (raw != null) refreshTokens.revoke(raw);
        return ResponseEntity.noContent()
                .header(HttpHeaders.SET_COOKIE, cookie("", Duration.ZERO).toString()).build();
    }

    private ResponseEntity<LoginResponse> withCookie(String raw, LoginResponse body) {
        return ResponseEntity.ok()
                .header(HttpHeaders.SET_COOKIE, cookie(raw, Duration.ofDays(7)).toString())
                .body(body);
    }

    private ResponseCookie cookie(String value, Duration maxAge) {
        return ResponseCookie.from(COOKIE, value)
                .httpOnly(true)          // JavaScript cannot read it
                .secure(true)            // HTTPS only
                .sameSite("Strict")      // not sent on cross-site requests
                .path("/api/auth")       // only sent to the auth endpoints
                .maxAge(maxAge)
                .build();
    }
}

Rotation and Reuse Detection in Plain Java

Example

Every refresh token works once. If an old one shows up again, either the user or a thief is replaying it, and we cannot tell who, so the whole login (family) is revoked:

import java.util.*;

public class Main {
    record Token(String user, String family, long expiresAt) {}

    static final Map<String, Token> active = new LinkedHashMap<>();
    static final Set<String> used = new HashSet<>();
    static final Set<String> revokedFamilies = new HashSet<>();
    static int counter = 0;

    static String issue(String user, String family, long now) {
        String id = "rt-" + (++counter);
        active.put(id, new Token(user, family, now + 7 * 24 * 3600));
        return id;
    }

    static String refresh(String id, long now) {
        if (used.contains(id)) {
            String family = active.get(id).family();
            revokedFamilies.add(family);
            return "REUSE DETECTED -> family " + family + " revoked, 401";
        }
        Token t = active.get(id);
        if (t == null) return "unknown token, 401";
        if (revokedFamilies.contains(t.family())) return "family revoked, 401";
        if (now >= t.expiresAt()) return "expired, 401";
        used.add(id);
        return "200 new access token + " + issue(t.user(), t.family(), now);
    }

    public static void main(String[] args) {
        long now = 1767225600L;
        String r1 = issue("[email protected]", "login-A", now);
        System.out.println("login                  -> " + r1);
        System.out.println("refresh " + r1 + " (user)    -> " + refresh(r1, now + 900));
        System.out.println("refresh rt-2 (user)    -> " + refresh("rt-2", now + 1800));
        System.out.println("refresh " + r1 + " (thief)   -> " + refresh(r1, now + 2000));
        System.out.println("refresh rt-3 (user)    -> " + refresh("rt-3", now + 2700));
        String r4 = issue("[email protected]", "login-B", now);
        System.out.println("ravi after 8 days      -> " + refresh(r4, now + 8 * 24 * 3600));
    }
}

Output

login                  -> rt-1
refresh rt-1 (user)    -> 200 new access token + rt-2
refresh rt-2 (user)    -> 200 new access token + rt-3
refresh rt-1 (thief)   -> REUSE DETECTED -> family login-A revoked, 401
refresh rt-3 (user)    -> family revoked, 401
ravi after 8 days      -> expired, 401