Repository navigation
security: hash refresh tokens at rest (logs everyone out once) - #10
Merged
Merged
Conversation
The column was named token_hash but stored the raw value:
RefreshToken.builder().tokenHash(generateRefreshToken())
generateRefreshToken returned 64 SecureRandom bytes base64'd, handed
straight to the client, and findByTokenHash redeemed by direct equality
against that same string. Any read of the table -- a leaked backup, a
misconfigured replica, SQL injection elsewhere, ORM debug logging --
came away with directly usable 7-day sessions requiring no further
work. Generation was already sound; this was purely at-rest exposure.
Now stores sha256(pepper || plaintext) hex-encoded and looks up by
digest, matching VerificationTokenService and EnrollmentTokenService.
A plain digest rather than bcrypt is deliberate: the input is 512 bits
of SecureRandom, so there is no guessable secret to slow an attacker
down to, and redemption must stay one indexed equality lookup.
Rotation on refresh, the 5-token-per-user cap with oldest-eviction,
and revokeAllUserTokens are unchanged.
V8 deletes existing rows rather than rehashing in place. Those values
sat at rest in plaintext for the life of the table, so preserving them
would keep any already-leaked copy valid for another 7 days; and
neither SQLite nor stock Postgres has SHA-256 in plain SQL. Everyone
is logged out once at deploy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
IbraTech04
commented
Aug 26, 2026
IbraTech04
left a comment
Collaborator
Author
There was a problem hiding this comment.
This is mostly fine - just stylistic changes in the code itself
| @@ -0,0 +1,26 @@ | |||
| -- | |||
Collaborator
Author
There was a problem hiding this comment.
what is all this yap - bruh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
The column is named
token_hash. It stored the raw token.findByTokenHash(token)redeemed by direct equality against exactly the string the client holds. So any read primitive against that table — a leaked backup, a misconfigured replica, SQL injection somewhere else in the app, ORM debug logging — yielded directly replayable 7-day sessions with no cracking step at all.Generation was never the problem (
SecureRandom, 512 bits). This was purely at-rest exposure.VerificationTokenService.java:69-95already does it correctly for email-verification and password-reset tokens, so the codebase knew the pattern; refresh tokens just never got it.The fix
sha256(pepper || plaintext)hex-encoded; redeem by digest. MatchesVerificationTokenServiceandEnrollmentTokenService, including the optional pepper.IssuedRefreshToken(token, plaintext)record so there is exactly one point where it is available.SecureRandom, so there is no low-entropy secret to slow an attacker down to, and redemption has to stay a single indexed equality lookup.revokeAllUserTokens.V8 deletes existing rows rather than rehashing in place — every live session is logged out once, at deploy.
That is a deliberate choice, reasoned through in the migration comments:
Access tokens (15 min) keep working until they expire; the next refresh returns 401 and the client re-authenticates. No user data is touched. No schema change needed —
token_hashis alreadyvarchar(255) not null uniqueand a hex SHA-256 is 64 chars.Verification
Parallel migrations in both
db/migration/sqlite/anddb/migration/postgresql/perdocs/MIGRATIONS.md;MigrationParityTestconfirms lockstep.Merge note
Touches
AuthController.java, which the sibling access-token-revocation PR also edits. Expect a conflict there depending on merge order.🤖 Generated with Claude Code
https://claude.ai/code/session_011xQusSt39vP3c82gAUXRCm