Skip to content

security: hash refresh tokens at rest (logs everyone out once) - #10

Merged
IbraTech04 merged 1 commit into
minbar-featuresfrom
sec/unit4-hash-refresh-tokens
Aug 30, 2026
Merged

IbraTech04 merged 1 commit into
minbar-featuresfrom
sec/unit4-hash-refresh-tokens

Conversation

@IbraTech04

Copy link
Copy Markdown
Collaborator

The problem

The column is named token_hash. It stored the raw token.

private String generateRefreshToken() {
    byte[] randomBytes = new byte[64];
    secureRandom.nextBytes(randomBytes);
    return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
}
...
RefreshToken.builder()
    .tokenHash(generateRefreshToken())   // raw value stored verbatim

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-95 already does it correctly for email-verification and password-reset tokens, so the codebase knew the pattern; refresh tokens just never got it.

The fix

  • Store sha256(pepper || plaintext) hex-encoded; redeem by digest. Matches VerificationTokenService and EnrollmentTokenService, including the optional pepper.
  • Plaintext now exists only in the response to the client that asked for it, returned via a small IssuedRefreshToken(token, plaintext) record so there is exactly one point where it is available.
  • A plain digest rather than bcrypt/argon2 is deliberate: the input is 512 bits of SecureRandom, so there is no low-entropy secret to slow an attacker down to, and redemption has to stay a single indexed equality lookup.
  • Unchanged: rotation on refresh (old revoked, new issued), the per-user cap of 5 with oldest-eviction, revokeAllUserTokens.

⚠️ Deploy consequence

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:

  • Those values have been at rest in plaintext for the life of the table. Rehashing them preserves any already-leaked copy for another 7 days. Deleting is the clean cut.
  • Neither SQLite nor a stock Postgres has SHA-256 available in plain SQL without an extension.
  • Nothing is salvageable anyway: a client presenting an old token would hash it, miss, and be rejected.

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_hash is already varchar(255) not null unique and a hex SHA-256 is 64 chars.

Verification

MigrationParityTest         tests=3   failures=0  errors=0
RefreshTokenServiceTest     tests=21  failures=0  errors=0
---
Tests run: 581, Failures: 0, Errors: 0, Skipped: 0     BUILD SUCCESS

Parallel migrations in both db/migration/sqlite/ and db/migration/postgresql/ per docs/MIGRATIONS.md; MigrationParityTest confirms 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

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
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@IbraTech04 IbraTech04 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mostly fine - just stylistic changes in the code itself

@@ -0,0 +1,26 @@
--

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is all this yap - bruh

@IbraTech04
IbraTech04 merged commit 495c773 into minbar-features Aug 30, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant