Sessions: JWT in httpOnly cookies, reworked from core-project #350

Closed
opened 2026-10-11 12:59:31 +00:00 by root · 8 comments
root commented 2026-10-11 12:59:31 +00:00 (Migrated from gitlab.core-project.space)

Imported from GitHub amaurycoudr/chess-project#350, opened by @amaurycoudr on 2026-10-11 12:59 UTC.
Closed on GitHub on 2026-10-11 13:45 UTC as completed.
Assigned on GitHub to @amaurycoudr.


Parent

#346

Question

core-project issues access and refresh JWTs as bearer tokens stored client-side, with the refresh token in Redis keyed by user — one live session per user. For a same-origin web app: JWT in httpOnly cookies, but where does the refresh token live (Postgres or Redis), how do several devices coexist, how is a session revoked (sign-out, password reset, account deletion), and is CSRF covered by the origin guard (ADR 0028) plus SameSite? Ends in an ADR.

> _Imported from GitHub [amaurycoudr/chess-project#350](https://github.com/amaurycoudr/chess-project/issues/350), opened by [@amaurycoudr](https://github.com/amaurycoudr) on 2026-10-11 12:59 UTC._ > _Closed on GitHub on 2026-10-11 13:45 UTC as completed._ > _Assigned on GitHub to [@amaurycoudr](https://github.com/amaurycoudr)._ <!-- github-issue:350 --> --- ## Parent #346 ## Question core-project issues access and refresh JWTs as bearer tokens stored client-side, with the refresh token in Redis keyed by user — one live session per user. For a same-origin web app: JWT in httpOnly cookies, but where does the refresh token live (Postgres or Redis), how do several devices coexist, how is a session revoked (sign-out, password reset, account deletion), and is CSRF covered by the origin guard (ADR 0028) plus SameSite? Ends in an ADR.
root commented 2026-10-11 12:59:31 +00:00 (Migrated from gitlab.core-project.space)

GitHub issue relations, imported as "related" links (GitLab CE has no sub-issues or blocking links).

> _GitHub issue relations, imported as "related" links (GitLab CE has no sub-issues or blocking links)._ <!-- github-relations --> - Parent (sub-issue of): #346 - Blocking: #352, #351
root commented 2026-10-11 13:45:52 +00:00 (Migrated from gitlab.core-project.space)

Imported from a GitHub comment by @amaurycoudr on 2026-10-11 13:45 UTC.

Resolution

ADR 0030, PR #356.

  1. Storage: Postgres, one sessions row per signed-in browser: the SHA-256 hash of the refresh token, created_at, last_used_at, expires_at and the user agent. An Account has any number of them. No Redis: a Session is Account data that cascades on deletion, and a refresh every 15 minutes per browser is no load. Whether Rooms move to Redis is a separate question, now noted in the map's fog.
  2. Access token: a stateless JWT valid 15 minutes, carrying sub (the Account) and sid (the Session), HS256 with a secret per stack. Revocation lags by at most 15 minutes. A deleted Account is refused at once, because authenticated procedures load it.
  3. Revocation:
    • Sign-out deletes this Session.
    • A password reset deletes all of them.
    • A password change deletes the other ones.
    • Deleting the Account cascades.
    • No device list and no "sign out everywhere" button in v1. The columns from point 1 allow them later.
  4. Rotation: on every refresh, with a 30-second grace period for the token it replaced. Reusing an old token after that revokes that Session. The front refreshes single-flight, one refresh per tab at a time.
  5. Refresh: explicit. On an UNAUTHORIZED, the front calls auth.refresh, then retries the call once. Nothing is refreshed transparently in middleware.
  6. Lifetime: rolling 30 days, no absolute cap, no "remember me".
  7. Cookies:
    • __Host-access: Secure; HttpOnly; Path=/; SameSite=Lax.
    • __Secure-refresh: Secure; HttpOnly; SameSite=Strict, its path limited to the refresh procedure.
    • Never a Domain attribute, so production and the previews never receive each other's cookies.
    • Why cookies rather than a header like the Seat token: an XSS or a compromised dependency can't exfiltrate a 30-day refresh token. It can only act through the victim's open tab.
  8. CSRF: no token. The origin guard (ADR 0028) is the protection, because SameSite doesn't stop pr-N.chess.core-project.space or other *.core-project.space apps, which are same-site with production. It holds while two invariants hold: no GET procedure changes state, and no CORS is opened.
  9. Glossary (in the PR):
    • Account: a Player's lasting identity: an email, a password, and the Games they own.
    • Session: one signed-in browser of an Account, revoked on its own.
    • "session" stays an alias to avoid for Game, Room and Connection.

Left to the Accounts v1 spec: the oRPC auth contract (core-project's shape), pruning expired Session rows, and the exact source paths, which the ADR's Governs column guesses as apps/api/src/auth/** and apps/front/src/auth/**.

> _Imported from a [GitHub comment](https://github.com/amaurycoudr/chess-project/issues/350#issuecomment-6109677801) by [@amaurycoudr](https://github.com/amaurycoudr) on 2026-10-11 13:45 UTC._ <!-- github-comment:6109677801 --> ## Resolution ADR 0030, [PR #356](https://github.com/amaurycoudr/chess-project/pull/356). 1. **Storage:** Postgres, one `sessions` row per signed-in browser: the SHA-256 hash of the refresh token, created_at, last_used_at, expires_at and the user agent. An **Account** has any number of them. No Redis: a **Session** is Account data that cascades on deletion, and a refresh every 15 minutes per browser is no load. Whether **Rooms** move to Redis is a separate question, now noted in the map's fog. 2. **Access token:** a stateless JWT valid 15 minutes, carrying `sub` (the Account) and `sid` (the Session), HS256 with a secret per stack. Revocation lags by at most 15 minutes. A deleted Account is refused at once, because authenticated procedures load it. 3. **Revocation:** - Sign-out deletes this Session. - A password reset deletes all of them. - A password change deletes the other ones. - Deleting the Account cascades. - No device list and no "sign out everywhere" button in v1. The columns from point 1 allow them later. 4. **Rotation:** on every refresh, with a 30-second grace period for the token it replaced. Reusing an old token after that revokes **that Session**. The front refreshes single-flight, one refresh per tab at a time. 5. **Refresh:** explicit. On an UNAUTHORIZED, the front calls `auth.refresh`, then retries the call once. Nothing is refreshed transparently in middleware. 6. **Lifetime:** rolling 30 days, no absolute cap, no "remember me". 7. **Cookies:** - `__Host-access`: `Secure; HttpOnly; Path=/; SameSite=Lax`. - `__Secure-refresh`: `Secure; HttpOnly; SameSite=Strict`, its path limited to the refresh procedure. - Never a `Domain` attribute, so production and the previews never receive each other's cookies. - Why cookies rather than a header like the Seat token: an XSS or a compromised dependency can't exfiltrate a 30-day refresh token. It can only act through the victim's open tab. 8. **CSRF:** no token. The origin guard (ADR 0028) is the protection, because SameSite doesn't stop `pr-N.chess.core-project.space` or other `*.core-project.space` apps, which are same-site with production. It holds while two invariants hold: no GET procedure changes state, and no CORS is opened. 9. **Glossary** (in the PR): - **Account**: a Player's lasting identity: an email, a password, and the Games they own. - **Session**: one signed-in browser of an Account, revoked on its own. - "session" stays an alias to avoid for Game, Room and Connection. **Left to the Accounts v1 spec:** the oRPC auth contract (core-project's shape), pruning expired Session rows, and the exact source paths, which the ADR's Governs column guesses as `apps/api/src/auth/**` and `apps/front/src/auth/**`.
root (Migrated from gitlab.core-project.space) closed this issue 2026-10-11 13:45:53 +00:00
root commented 2026-10-13 08:39:38 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #351

mentioned in issue #351
root commented 2026-10-13 08:39:38 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #352

mentioned in issue #352
root commented 2026-10-13 08:41:16 +00:00 (Migrated from gitlab.core-project.space)

mentioned in issue #346

mentioned in issue #346
root commented 2026-10-13 08:42:21 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #346

marked this issue as related to #346
root commented 2026-10-13 08:42:23 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #352

marked this issue as related to #352
root commented 2026-10-13 08:42:24 +00:00 (Migrated from gitlab.core-project.space)

marked this issue as related to #351

marked this issue as related to #351
Sign in to join this conversation.
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.

Reference
amaurycoudr/chess-project#350
No description provided.