diff --git a/docs/authentication-methods.md b/docs/authentication-methods.md
index 1e97ec7..d3d2aa7 100644
--- a/docs/authentication-methods.md
+++ b/docs/authentication-methods.md
@@ -219,6 +219,13 @@ evidence that the SuperTokens path works end to end.
## Phase 5 — `supertokens` mode — blocked on client work
+> **Implementation plan written 2026-08-08:**
+> [`superpowers/plans/2026-08-08-v1.9-supertokens-client.md`](./superpowers/plans/2026-08-08-v1.9-supertokens-client.md).
+> Not started. Task 1 of that plan drives the SuperTokens login by hand against
+> production `dual` — no code — which is the cheapest way to find out whether
+> the identity mapping actually works end to end. Do that before writing any
+> client code.
+
**This is the honest state: `supertokens`-only mode cannot be used yet, and the
blocker is larger than "not recommended".**
diff --git a/docs/superpowers/plans/2026-08-08-v1.9-supertokens-client.md b/docs/superpowers/plans/2026-08-08-v1.9-supertokens-client.md
new file mode 100644
index 0000000..451e220
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-08-v1.9-supertokens-client.md
@@ -0,0 +1,223 @@
+# v1.9 — SuperTokens client integration (Phase 5)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: use `superpowers:subagent-driven-development`
+> or `superpowers:executing-plans`. Steps use `- [ ]` checkboxes.
+
+**Status: NOT STARTED.** Written 2026-08-08 at the end of a session, so the next
+one can begin immediately.
+
+**Goal:** teach the client to authenticate through SuperTokens, so
+`AUTH_MODE=supertokens` becomes usable and the `signInUp` mapping is exercised
+by real logins.
+
+## Where things stand (read this first)
+
+`AUTH_MODE=dual` is **live in production** on Unraid, RackStack v1.8.4, core 12
+at `192.168.68.50:3567`. Both gates passed against the real box:
+`shadow:check` 6/6 100%, `supertokens:check` all 10 PASS.
+
+The **entire server side is built, tested and deployed.** Nothing in this plan
+requires server work unless a step below says so explicitly.
+
+What is missing is only the client:
+
+1. **No SuperTokens login flow.** `client/src/Login.jsx` hardcodes
+ `` and `` — the *passport*
+ routes. `supertokens` mode does not register them, so the buttons fall
+ through to the SPA and nobody can sign in.
+2. **No token refresh.** No frontend SDK, so nothing renews an expired access
+ token. Invisible in `dual` (the legacy JWT cookie still authenticates);
+ in `supertokens`-only mode a player is silently logged out when the access
+ token expires and their legacy cookie has also lapsed.
+
+**Consequence worth internalising:** because the client logs in via passport,
+`dual` has never exercised the SuperTokens `signInUp` mapping. It is thoroughly
+unit-tested — including the ordering guarantee that the user-id mapping is
+created before the session — but has never run against a real GitHub login.
+**Step 1 below is the first time that path executes for real.**
+
+## Constraints (all still in force from v1.8)
+
+- **`users.id` never changes.** It is `provider:providerId`. The mapping puts
+ SuperTokens' internal id *onto* it; `session.getUserId()` must return
+ `github:37058311`.
+- **Nobody gets logged out.** Legacy 90-day JWT cookies stay valid through
+ every transition, both directions. Rollback is `AUTH_MODE=passport` + restart.
+- **The allowlist is the SuperTokens HTTP surface.** `server/app.js`'s
+ `gateSuperTokensPaths` permits only `/auth/authorisationurl`,
+ `/auth/signinup`, `/auth/session/refresh` and `/auth/callback/*`. Anything
+ else the client needs must be added there **deliberately** — that gate exists
+ because `supertokens.init()` auto-adds six recipes and 13 endpoints,
+ including a second half-logout (`POST /auth/oauth/logout`). Do not widen it
+ casually.
+- **`POST /auth/signinup` accepts only the redirect flow.** Submitting
+ `oAuthTokens` directly is rejected (`rejectRawOAuthTokens`) — that was an
+ account-takeover bypass. The client must use `redirectURIInfo`.
+- **`/auth/signout` is deliberately removed.** Use `/auth/logout`, which clears
+ both stacks. The SuperTokens frontend SDK's `signOut()` targets
+ `/auth/signout` — if you adopt the SDK, point it at `/auth/logout` or the
+ logout will half-work.
+- **Both backends still tested.** `npm run test:all` green on SQLite and
+ Postgres.
+- **Docs updated in the same task that changes behaviour.**
+
+## Decision to make before Task 1
+
+**Use `supertokens-web-js`, or hand-roll the three fetch calls?**
+
+| | SDK | Hand-rolled |
+|---|---|---|
+| Refresh | Handled, incl. concurrent-request queueing | Must write it |
+| Bundle | +~30 kB | none |
+| Surface | Pulls in more than we allowlist | Exactly three calls |
+| Risk | Its `signOut()` hits the removed `/auth/signout` | Fully under our control |
+
+**Recommendation: hand-roll.** The flow is three calls, the allowlist is
+already exactly those three, and the SDK's assumptions (its own signout path,
+its own session storage) cut against decisions v1.8 made deliberately. Revisit
+if refresh-under-concurrency proves fiddly.
+
+---
+
+### Task 1: Prove the SuperTokens login path works at all
+
+**Do this before writing any client code.** It is the cheapest possible way to
+find out whether the mapping works end to end, and it needs no code.
+
+- [ ] **Step 1: Drive the flow by hand against production `dual`**
+
+```bash
+# 1. Get the provider URL (returns JSON with urlWithQueryParams)
+curl -s 'https://rackstack.neverendingcode.com/auth/authorisationurl?thirdPartyId=github&redirectURIOnProviderDashboard=https://rackstack.neverendingcode.com/auth/callback/github'
+```
+
+Open `urlWithQueryParams` in a browser, authorise, and let GitHub redirect to
+`/auth/callback/github?code=…&state=…`. Copy those query params, then:
+
+```bash
+curl -s -X POST 'https://rackstack.neverendingcode.com/auth/signinup' \
+ -H 'Content-Type: application/json' \
+ -d '{"thirdPartyId":"github","redirectURIInfo":{
+ "redirectURIOnProviderDashboard":"https://rackstack.neverendingcode.com/auth/callback/github",
+ "redirectURIQueryParams":{"code":"","state":""}}}'
+```
+
+- [ ] **Step 2: Assert the thing the whole release rests on**
+
+Expect `status: "OK"` and **`user.id === "github:37058311"`** — your existing
+`users.id`, not a SuperTokens UUID. If it is a UUID, the mapping did not run;
+stop and debug before writing any client code.
+
+Then confirm on the box:
+
+```sql
+SELECT provider, provider_id, supertokens_user_id FROM identities;
+```
+
+`supertokens_user_id` should now be populated for that row with a real
+SuperTokens id (**not** `github:37058311` — writing our own id back there was a
+bug fixed in v1.8; if you see it, that regressed).
+
+- [ ] **Step 3: Re-run both gates**
+
+`npm run shadow:check` must still be 6/6 with **0 new identities** — a new row
+would mean the login created a second account instead of matching the existing
+one, which is the exact failure v1.8 exists to prevent.
+
+> If Task 1 fails, everything below is premature. Fix the mapping first.
+
+---
+
+### Task 2: The login flow in the client
+
+**Files:** `client/src/Login.jsx`, new `client/src/game/auth.js`
+
+- [ ] **Step 1: Detect which stack to drive**
+
+The client must not hardcode either. Add `authMode` to `GET /api/config`
+(server-side, one line — it is the only server change in this plan) and have
+`Login.jsx` render passport `` links when passport is enabled, and
+SuperTokens buttons when it is not.
+
+Rationale: hardcoding SuperTokens would break `passport` mode, which is still
+the default and the documented rollback. Both must work from one build.
+
+- [ ] **Step 2: Implement `startSuperTokensLogin(providerId)`**
+
+`GET /auth/authorisationurl?thirdPartyId=&redirectURIOnProviderDashboard=/auth/callback/`
+→ `window.location.assign(urlWithQueryParams)`. Persist nothing; the state
+param round-trips through the provider.
+
+- [ ] **Step 3: Handle the callback route**
+
+`/auth/callback/:provider` currently falls to the SPA. Have the app detect that
+path on load, `POST /auth/signinup` with `redirectURIInfo` built from
+`window.location.search`, then replace history with `/` on success.
+
+`credentials: 'include'` on both calls, or the session cookie is dropped.
+
+- [ ] **Step 4: Failure paths**
+
+`GENERAL_ERROR` (the raw-token refusal), `SIGN_IN_UP_NOT_ALLOWED`, and a
+provider `error=access_denied` must each land the player back on the login
+screen with a readable message — reuse the existing `?authError=` convention.
+
+- [ ] **Step 5: Tests**
+
+An e2e smoke suite (`tests/e2e/smoke-v19.mjs`, matching the existing
+`smoke-v1*.mjs` glob) that stubs the provider and drives the full flow against
+a real app in `dual`, asserting the session resolves to the pre-existing
+`users.id` and the existing save.
+
+---
+
+### Task 3: Session refresh
+
+**Files:** `client/src/game/api.js`
+
+- [ ] **Step 1: Refresh on 401**
+
+Wrap the existing fetch helper: on a 401, `POST /auth/session/refresh` once,
+then retry the original request. On a second 401, fall through to the login
+screen.
+
+- [ ] **Step 2: Serialize concurrent refreshes**
+
+The app fires several requests at once; a naive implementation sends N refresh
+calls and races them. One in-flight refresh promise, shared — the same shape as
+`server/userLock.js`. **This is the part most likely to be got wrong.**
+
+- [ ] **Step 3: Do not break passport mode**
+
+In `passport` mode a 401 means "not logged in" and there is nothing to refresh.
+Gate the refresh attempt on the same `authMode` from Task 2 Step 1.
+
+- [ ] **Step 4: Test both**
+
+A 401→refresh→retry succeeds; two concurrent 401s produce exactly **one**
+refresh call; passport mode never calls refresh.
+
+---
+
+### Task 4: Cutover to `supertokens` and release
+
+- [ ] **Step 1: Verify in `dual` first** — with the client on SuperTokens and
+ `AUTH_MODE=dual`, a legacy cookie must *still* authenticate. That is the
+ no-forced-logout guarantee under the new client.
+- [ ] **Step 2: `AUTH_MODE=supertokens`** on the box; confirm login, refresh
+ across an access-token expiry, and logout clearing both stacks.
+- [ ] **Step 3: Un-block the docs.** `docs/authentication-methods.md` Phase 5,
+ the runbook's D6 and "what has NOT been verified", `README.md`,
+ `.env.example` and `unraid-template.xml` all currently say
+ `supertokens` mode is unusable. That stops being true here — update all
+ five, and do not leave a stale warning behind.
+- [ ] **Step 4: Version, changelog, tag** — `package.json` + Dockerfile label
+ (`client/package.json` is deliberately NOT bumped; `client/vite.config.js`
+ reads the root as the single version authority). Tag `main`, never the
+ branch — that push is what triggers the GHCR publish.
+
+---
+
+## Findings and deviations
+
+_(record as work proceeds, so the plan does not quietly diverge)_