fix(auth): [operator-action] hash session tokens at rest #496

Merged
YousifShkara merged 1 commit from fix/LC-514-sessions-hash-at-rest into main 2026-06-30 06:01:05 +02:00
Owner

LC-514: sessions.id held the RAW session cookie value (a 64-char OsRng alphanumeric token). A read-only DB compromise (backup leak, replica with read access, ops-shell SELECT) handed the attacker live session cookies that could be pasted straight into the session= cookie header to impersonate users without ever cracking a password. The audit named this high-severity.

The cookie value itself is a 256-bit opaque random token; storing the plaintext as the lookup key treats the DB as a secret-keeping vault, which it isn't. Now: the cookie still carries the plaintext, but every lookup hashes the presented value via SHA-256 before the WHERE clause, and the DB only ever stores hashes.

Pieces:

  • New db::auth::hash_session_token(token) -> String (SHA-256 hex, idempotent on the token-shape).
  • create_session_with_origin writes SHA-256(token) as sessions.id; the plaintext is returned to the caller for the cookie set as before.
  • get_user_by_session, touch_session_last_seen, and the two delete_session* paths hash the presented cookie before the WHERE clause.
  • routes/settings.rs::post_session_revoke and the is_current marker in the sessions list both hash the cookie value before comparing against the row id (which is now the hash).
  • lookup_session_id is the per-call helper that accepts either a raw cookie (hashes it) or an already-hashed row id (passes through). The settings UI's "Sign out this session" button posts the listed id (the hash) and that still works.

backfill_sessions_hashed_at_rest runs once at startup after migration 0034. Existing rows have their id updated in place to SHA-256(id) so the lookup against the new hashed key matches the cookies that are already in flight; no user is forced through a re-login. Idempotent (gated by the sessions_hash_migration_marker table, AND skips rows whose id is already SHA-256 hex), and survives crashes mid-backfill since each UPDATE is its own transaction.

Operator-Action: the next startup after this deploy runs a one-shot in-place re-hash of every existing sessions row. Existing user sessions stay valid because the cookie value is the input to the new hashed lookup. A backup taken BEFORE the deploy still leaks the plaintext cookies it captured; rotate / invalidate those backups after the deploy if their cookie window has not expired (default cookie TTL is 30 days).

#LC-514

LC-514: `sessions.id` held the RAW session cookie value (a 64-char OsRng alphanumeric token). A read-only DB compromise (backup leak, replica with read access, ops-shell SELECT) handed the attacker live session cookies that could be pasted straight into the `session=` cookie header to impersonate users without ever cracking a password. The audit named this high-severity. The cookie value itself is a 256-bit opaque random token; storing the plaintext as the lookup key treats the DB as a secret-keeping vault, which it isn't. Now: the cookie still carries the plaintext, but every lookup hashes the presented value via SHA-256 before the WHERE clause, and the DB only ever stores hashes. Pieces: - New `db::auth::hash_session_token(token) -> String` (SHA-256 hex, idempotent on the token-shape). - `create_session_with_origin` writes `SHA-256(token)` as `sessions.id`; the plaintext is returned to the caller for the cookie set as before. - `get_user_by_session`, `touch_session_last_seen`, and the two `delete_session*` paths hash the presented cookie before the WHERE clause. - `routes/settings.rs::post_session_revoke` and the `is_current` marker in the sessions list both hash the cookie value before comparing against the row id (which is now the hash). - `lookup_session_id` is the per-call helper that accepts either a raw cookie (hashes it) or an already-hashed row id (passes through). The settings UI's "Sign out this session" button posts the listed id (the hash) and that still works. `backfill_sessions_hashed_at_rest` runs once at startup after migration 0034. Existing rows have their `id` updated in place to `SHA-256(id)` so the lookup against the new hashed key matches the cookies that are already in flight; no user is forced through a re-login. Idempotent (gated by the `sessions_hash_migration_marker` table, AND skips rows whose id is already SHA-256 hex), and survives crashes mid-backfill since each UPDATE is its own transaction. Operator-Action: the next startup after this deploy runs a one-shot in-place re-hash of every existing `sessions` row. Existing user sessions stay valid because the cookie value is the input to the new hashed lookup. A backup taken BEFORE the deploy still leaks the plaintext cookies it captured; rotate / invalidate those backups after the deploy if their cookie window has not expired (default cookie TTL is 30 days). #LC-514
fix(auth): [operator-action] hash session tokens at rest
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 4m46s
check-secrets / TruffleHog (push) Successful in 4s
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 5s
189670227f
LC-514: `sessions.id` held the RAW session cookie value (a 64-char OsRng alphanumeric token). A read-only DB compromise (backup leak, replica with read access, ops-shell SELECT) handed the attacker live session cookies that could be pasted straight into the `session=` cookie header to impersonate users without ever cracking a password. The audit named this high-severity.

The cookie value itself is a 256-bit opaque random token; storing the plaintext as the lookup key treats the DB as a secret-keeping vault, which it isn't. Now: the cookie still carries the plaintext, but every lookup hashes the presented value via SHA-256 before the WHERE clause, and the DB only ever stores hashes.

Pieces:

- New `db::auth::hash_session_token(token) -> String` (SHA-256 hex, idempotent on the token-shape).
- `create_session_with_origin` writes `SHA-256(token)` as `sessions.id`; the plaintext is returned to the caller for the cookie set as before.
- `get_user_by_session`, `touch_session_last_seen`, and the two `delete_session*` paths hash the presented cookie before the WHERE clause.
- `routes/settings.rs::post_session_revoke` and the `is_current` marker in the sessions list both hash the cookie value before comparing against the row id (which is now the hash).
- `lookup_session_id` is the per-call helper that accepts either a raw cookie (hashes it) or an already-hashed row id (passes through). The settings UI's "Sign out this session" button posts the listed id (the hash) and that still works.

`backfill_sessions_hashed_at_rest` runs once at startup after migration 0034. Existing rows have their `id` updated in place to `SHA-256(id)` so the lookup against the new hashed key matches the cookies that are already in flight; no user is forced through a re-login. Idempotent (gated by the `sessions_hash_migration_marker` table, AND skips rows whose id is already SHA-256 hex), and survives crashes mid-backfill since each UPDATE is its own transaction.

Operator-Action: the next startup after this deploy runs a one-shot in-place re-hash of every existing `sessions` row. Existing user sessions stay valid because the cookie value is the input to the new hashed lookup. A backup taken BEFORE the deploy still leaks the plaintext cookies it captured; rotate / invalidate those backups after the deploy if their cookie window has not expired (default cookie TTL is 30 days).

#LC-514
YousifShkara deleted branch fix/LC-514-sessions-hash-at-rest 2026-06-30 06:01:05 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!496
No description provided.