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
- Log in and obtain a refresh token (set as the
refresh_token HttpOnly cookie)
- Call
POST /api/auth/refresh with the valid refresh token — the token
rotates and the old token is marked revoked = true in the database
- Call
POST /api/auth/refresh again, this time reusing the old
(now-revoked) refresh token
- 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
- Add
Optional<RefreshToken> findByTokenId(String tokenId); to
RefreshTokenRepository (without the revoked filter)
- 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
What happened?
AuthService.refresh()andAuthService.logout()look up refresh tokensvia
RefreshTokenRepository.findByTokenIdAndRevokedFalse(), which excludesalready-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
AuthServiceclass Javadoc explicitly states that "revoked tokens maybe 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
tokenIdalone (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
refresh_tokenHttpOnly cookie)POST /api/auth/refreshwith the valid refresh token — the tokenrotates and the old token is marked
revoked = truein the databasePOST /api/auth/refreshagain, this time reusing the old(now-revoked) refresh token
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 Unauthorizedas aninvalid 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
the core of the bug. Relevant code:
Environment
mainbranch)Suggested fix
Optional<RefreshToken> findByTokenId(String tokenId);toRefreshTokenRepository(without the revoked filter)AuthService.refresh(), if the token is found butrevoked == true,treat it as a confirmed replay attack — log the security event and
optionally call
revokeAllByUser()to invalidate all sessions forthat user