Skip to content

Refresh token reuse (replay attack) is not detected — revoked tokens excluded from lookup queries #260

Description

@prashantpiyush1111

What happened?

AuthService.refresh() and AuthService.logout() look up refresh tokens
via RefreshTokenRepository.findByTokenIdAndRevokedFalse(), which excludes
already-revoked tokens from the query entirely. This means a revoked
(reused/replayed) refresh token, an expired token, and a token that never
existed all return the exact same generic 401 response.

The AuthService class Javadoc explicitly states that "revoked tokens may
be retained temporarily to allow replay-attack detection and auditing,"
but no code path actually performs this detection — there is no repository
method to fetch a token by tokenId alone (without the revoked filter),
so a reuse attempt cannot be distinguished from an invalid token. No log,
alert, or audit event is ever generated.

Steps to reproduce

  1. Log in and obtain a refresh token (set as the refresh_token HttpOnly cookie)
  2. Call POST /api/auth/refresh with the valid refresh token — the token
    rotates and the old token is marked revoked = true in the database
  3. Call POST /api/auth/refresh again, this time reusing the old
    (now-revoked) refresh token
  4. Observe the response and check for any security logging

Expected

Reusing a revoked refresh token should be treated as a strong signal of
token theft. The system should detect this case specifically (not just
return a generic 401), log a security event, and ideally revoke all
other active sessions for that user as a defense-in-depth measure.

Actual

The reused/revoked token returns the same 401 Unauthorized as an
invalid or expired token, with zero logging or alerting. There is
currently no way to tell from the response or from logs whether a
replay attack occurred.

Evidence

  • Screenshot/video: N/A (backend logic issue, confirmed via code review)
  • Logs/error message: No log line is produced in this path — that is
    the core of the bug. Relevant code:
// AuthService.refresh()
RefreshToken storedToken =
    refreshTokenRepository.findByTokenIdAndRevokedFalse(tokenId).orElse(null);
...
if (storedToken == null || !hashMatch || expired) {
    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// RefreshTokenRepository — no way to look up a revoked token by tokenId
Optional<RefreshToken> findByTokenIdAndRevokedFalse(String tokenId);

Environment

  • Where: dev (reviewed directly from source on main branch)
  • Browser/OS: N/A (backend/API-level issue)
  • Feature flag(s) (if any): N/A

Suggested fix

  1. Add Optional<RefreshToken> findByTokenId(String tokenId); to
    RefreshTokenRepository (without the revoked filter)
  2. In AuthService.refresh(), if the token is found but revoked == true,
    treat it as a confirmed replay attack — log the security event and
    optionally call revokeAllByUser() to invalidate all sessions for
    that user

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions