Production-shaped authentication: access/refresh JWT in httpOnly cookies, rotation with reuse detection, TOTP 2FA with backup codes, GitHub OAuth, CSRF, and a storage layer where a database leak yields no usable credential — on a fully typed, tested, containerized stack.
→ Threat model — the attacks this design accounts for, and the test that fails if each defence is removed.
The API and the SPA are two independent apps (
/server,/client) that share only an HTTP contract.
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
- 🗒️ Notes — a per-user CRUD resource on top of auth with owner-only access, so this is an app with authentication, not just a login form.
- 🌐 Profiles & feed — unique display names, a public/private profile toggle, and a feed of other verified users' public posts (unverified users can still write their own).
- 🔐 JWT auth with short-lived access + long-lived refresh tokens, both in
httpOnly+SameSite=Strictcookies (no tokens inlocalStorage). - 🔄 Refresh-token rotation with reuse detection — a replayed (already-rotated) token invalidates every session for that user, while a deliberately revoked session is tombstoned so revoking one device (or changing your password) does not cascade into logging you out everywhere.
- 🔑 Nothing usable at rest — activation, refresh, reset and email-change tokens are stored as SHA-256 digests; backup codes carry 80 bits of entropy behind the same hash; TOTP secrets are encrypted (AES-256-GCM); passwords are bcrypt cost 12. A database leak yields no working credential.
- 📧 Email flows — account activation and password reset via nodemailer (Ethereal test inbox when SMTP is not configured), sent out of band so a slow SMTP hop is never a timing/enumeration oracle.
- 🛡️ Hardening —
helmeton the API and a Content-Security-Policy (plus the usual header set) on the SPA document from nginx; per-endpoint rate limits keyed on the account (the pre-auth subject for the 2FA step), proxy-aware client IPs, Zod request & env validation, a single JSON error contract, and noCookieheader in the logs. - 🔗 OAuth — "Continue with GitHub" (arctic). Links to an existing account only if it is verified — silently merging on a matching email is pre-account hijacking.
- 🔐 Two-factor auth (TOTP) — QR-code enrolment, single-use backup codes, replay-protected codes, and a two-step login via a short-lived pre-auth token. Turning 2FA off also requires the second factor.
- 🖥️ Multi-session — every login is its own session; list active devices and revoke them; changing the password logs out all others.
- 🔒 Account & CSRF — change email (confirmed on the new address, old one notified), delete account, and double-submit CSRF tokens on every mutating request, on top of SameSite.
- 📘 OpenAPI — interactive Swagger UI at
/api/docs. - 🌍 i18n — English / Spanish / German with a runtime language switcher; the server tags its user-facing errors with a code so the UI localises them instead of showing an English server string.
- ♿ Accessible — labelled fields with
autoComplete, per-fieldaria-invaliderrors with focus moved to the first invalid field,<output>/live-region announcements, a skip link and a focused<main>on navigation.jsx-a11ylint fails the build on a violation, and the primary pages (sign-in, register, reset request, feed, settings) are scanned with axe-core (WCAG 2.1 AA) in the E2E suite. - 🧪 Tested — 73 API tests (Vitest + supertest, including CSRF, rate limiting, refresh-reuse detection, revoke-without-cascade, token expiry, session invalidation, the JSON error contract, concurrent-code atomicity and every bypass listed below), 36 client unit tests (Testing Library), 21 Playwright E2E (incl. axe accessibility scans), with a coverage threshold enforced in CI. The integration suite runs against an ephemeral local Postgres —
npm run test:db:up && npm testfrom a clean clone. - 🐳 One-command stack —
docker compose upbrings up PostgreSQL + API + client.
| Layer | Tech |
|---|---|
| Client | React 19, TypeScript, Vite, Zustand (auth state), TanStack Query (server state), React Router, react-i18next, Tailwind CSS v4 |
| Server | Node.js, Express 5, TypeScript (strict), Zod, pino |
| Database | PostgreSQL, Prisma 7 (driver adapter) |
| Auth | jsonwebtoken, bcrypt, httpOnly cookies, otplib (TOTP), arctic (OAuth), node:crypto (AES-256-GCM) |
| Testing | Vitest, supertest, React Testing Library, Playwright, axe-core |
| Tooling | Docker Compose, GitHub Actions, oxlint (+ jsx-a11y), Prettier, Swagger UI |
flowchart LR
B[Browser] -->|/api proxy| C["React SPA<br/>Vite · Zustand · i18n"]
C -->|httpOnly cookies · withCredentials| A["Express 5 API<br/>TypeScript · Zod · pino"]
A --> P[Prisma 7 driver adapter]
P --> DB[(PostgreSQL)]
A -.-> M[Mail · nodemailer]
The server is layered so that HTTP concerns never leak into business logic:
routes → controllers → services → prisma
▲ ▲
middlewares dtos / schemas
(auth · validate · rate-limit · error)
createApp() is decoupled from the bootstrap (index.ts) so the whole app can be
mounted in tests via supertest without binding a port.
sequenceDiagram
participant U as Browser
participant A as API
participant D as DB
U->>A: POST /login
A->>D: store sha256(refreshToken)
A-->>U: Set-Cookie accessToken (15m) + refreshToken (30d), { user }
Note over U,A: access token expires
U->>A: GET /notes → 401
U->>A: GET /refresh (refreshToken cookie)
A->>D: look up sha256(token) → found, rotate
A-->>U: new token pair (the old one is now unknown to the DB)
U->>A: retry GET /notes → 200
Note over U,A: an attacker replays the OLD refresh token
U->>A: GET /refresh (stolen, already-rotated token)
A->>D: sha256 not found → treat as theft
A-->>U: 401, every session for that user dropped
The client funnels all concurrent 401s into a single /refresh — without that, two parallel
refreshes would race the rotation and the second would trip the theft detection above, logging the
user out for no reason.
docker compose up --build
# client → http://localhost:8080 api → http://localhost:5050Spins up PostgreSQL, runs migrations, and serves the API and the built client (nginx).
Requires Node 24+ and a PostgreSQL database (a free Neon project works great).
# server
cd server
cp .env.example .env # fill DATABASE_URL, the two JWT secrets, and TOTP_ENCRYPTION_KEY
npm install
npx prisma generate # generate the Prisma client (required before first run)
npx prisma migrate deploy # apply migrations — or: npm run prisma:migrate
npm run dev # http://localhost:5050
# client (second terminal)
cd client
npm install
npm run dev # http://localhost:3000 (proxies /api → :5050)Registration sends an activation email. Without a real SMTP server the email is not delivered to a real inbox — it is sent to an Ethereal test account and a preview link is printed to the server logs. So in dev you fetch the activation link yourself, two ways:
From the server logs — opens the actual email on Ethereal:
docker compose logs server | grep "Email preview" # Docker
# local dev: the preview URL is printed in the `npm run dev` outputThe database will not help you here: it stores only the SHA-256 digest of the token, not
the link — that is the point, a read-only leak yields nothing usable. So take the raw link from
the Ethereal preview above and open it:
http://localhost:5050/api/activate/<token> (5050 is the API port in Docker — use your
API_URL port in local dev). The server marks the account verified and redirects back to the
app; refresh if the banner is still shown.
Verification gates the social layer, not your own data: an unverified user can still
create and manage their own notes, but the feed and a public profile require a
verified email — they see a dismissible "verify your email" banner on the home feed and
GET /api/feed returns 403.
With a real SMTP configured (see env vars below), the activation link simply arrives by email.
The login screen always shows a "Continue with GitHub" button. To wire it up locally, register a GitHub OAuth App and hand the API its credentials:
-
GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.
-
Set Authorization callback URL to
http://localhost:3000/api/auth/github/callback— the client origin, because that is where the session cookies must land. -
Copy the Client ID, generate a Client secret, and add both to
server/.env:GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=...
-
Restart the server. Without these variables the button still renders, but the route redirects to
/login?error=oauth_unconfigured— OAuth is treated as an optional integration.
A first GitHub sign-in creates a passwordless, pre-verified account (or links to an existing account with the same email); password-based settings are hidden for those users.
| Variable | Required | Notes |
|---|---|---|
DATABASE_URL |
✅ | PostgreSQL connection string |
JWT_ACCESS_SECRET / JWT_REFRESH_SECRET |
✅ | ≥ 32 chars and must differ — openssl rand -hex 32 |
TOTP_ENCRYPTION_KEY |
✅ | 64 hex chars. Encrypts 2FA secrets at rest; losing it makes every enrolled authenticator unusable |
API_URL / CLIENT_URL |
✅ | used for links & CORS origin |
TRUST_PROXY |
– | number of reverse proxies in front of the app (default 0) — see below |
PORT |
– | default 5000 |
SMTP_* |
– | empty → Ethereal test inbox |
COOKIE_SECURE |
– | force Secure flag independently of NODE_ENV |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET |
– | GitHub OAuth; callback …/api/auth/github/callback (routes redirect to /login?error=oauth_unconfigured if unset) |
Env is parsed and validated with Zod at startup — the process exits on invalid config, so a too-short secret is a boot failure rather than a silent weakness.
TRUST_PROXYmatters more than it looks. Rate limiting keys on the client IP. Set it too low and everyone behind the proxy shares one bucket (the whole world gets 10 login attempts per 15 minutes); set it to a blankettrueand a client can forgeX-Forwarded-Forto get a fresh bucket per request. It must equal the actual number of hops:0running directly,1behind one nginx/load balancer.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /api/registration |
– | Register, send activation email |
| POST | /api/login |
– | Log in (returns twoFactorRequired if 2FA is on) |
| POST | /api/2fa/setup |
access cookie | Start 2FA setup (secret + otpauth URI) |
| POST | /api/2fa/enable |
access cookie | Enable 2FA with a TOTP code → returns backup codes once |
| POST | /api/2fa/disable |
access cookie | Disable 2FA (second factor + password, if the account has one) |
| POST | /api/2fa/backup-codes |
access cookie | Regenerate backup codes (second factor) |
| POST | /api/2fa/verify |
pre-auth cookie | Second login step (TOTP or backup code) |
| GET | /api/auth/github |
– | Start GitHub OAuth |
| GET | /api/auth/github/callback |
– | OAuth callback → session |
| POST | /api/logout |
– | Clear auth cookies |
| GET | /api/activate/:link |
– | Activate account, redirect to client |
| GET | /api/refresh |
refresh cookie | Rotate token pair |
| POST | /api/activation/resend |
access cookie | Resend the activation email |
| GET | /api/settings |
access cookie | Get profile settings |
| PATCH | /api/settings |
access cookie | Update display name / visibility |
| POST | /api/password/forgot |
– | Send reset link (always 200) |
| POST | /api/password/reset |
– | Set new password by token |
| PATCH | /api/password/change |
access cookie | Change password, log out other sessions |
| POST | /api/email/change |
access cookie | Request email change (confirm on new address) |
| GET | /api/email/confirm/:token |
– | Confirm the new email |
| DELETE | /api/account |
access cookie | Delete account (password-confirmed) |
| GET | /api/sessions |
access cookie | List active sessions/devices |
| DELETE | /api/sessions/:id |
access cookie | Revoke a session |
| GET | /api/docs |
– | Swagger UI (OpenAPI) |
| GET | /health |
– | Liveness probe |
| GET | /api/notes |
access cookie | List the current user's notes |
| POST | /api/notes |
access cookie | Create a note |
| PATCH | /api/notes/:id |
access cookie | Update own note |
| DELETE | /api/notes/:id |
access cookie | Delete own note |
| GET | /api/feed |
verified email | Public posts from other users |
# server — unit + integration against an ephemeral local Postgres (Docker)
cd server
npm run test:db:up # start Postgres (port 5433) + apply migrations
npm test # 73 tests
npm run test:coverage # same, with the coverage threshold enforced
npm run test:db:down # stop and wipe (optional)
# client — unit + component (Vitest + Testing Library)
cd client && npm test # 36 tests
# client — E2E (mocked API, real browser) — includes axe WCAG scans
cd client && npm run test:e2e # 21 testsThe security tests are the point, not decoration. Each of these fails if its fix is reverted:
| Test | What it proves |
|---|---|
treats a replayed, already-rotated refresh token as theft |
refresh-token reuse detection drops every session for the user |
revoking a session does not log the other sessions out |
a revoked session is tombstoned, so reuse detection tells it apart from a replay and does not cascade |
drops every session when the password is reset / …when the email is changed |
the recovery paths actually invalidate the old sessions |
rejects a reset token whose 1-hour TTL has passed / …email-change token… |
the token expiry is enforced, not just stored |
cancels a pending email change when the password is reset |
a live email-change link cannot survive a reset and move the recovery anchor |
answers an unknown route with a JSON 404 / a malformed JSON body with 400 |
one JSON error contract — never an HTML page or a 500 for a client mistake |
does not accept the pre-auth token as an access token |
2FA cannot be skipped by replaying the pre-auth cookie |
does not bypass 2FA via the OAuth callback |
the GitHub handshake earns a pre-auth token, not a session |
refuses to link to an existing UNVERIFIED account |
no pre-account hijacking via OAuth |
rejects a replayed TOTP code / accepts a TOTP code exactly once under concurrent use |
codes are single-use, even under a race (atomic step guard) |
disable requires the second factor, not just the password |
2FA guards its own removal |
encrypts the TOTP secret at rest / never stores the raw reset token / activates by the token digest |
a DB leak yields no usable credential |
rejects a header that does not match the cookie |
double-submit CSRF actually fires |
enforces the 2FA limit on the real /2fa/verify route |
rate limiting fires on the actual route, not a stand-in |
fires a single /refresh for concurrent 401s |
the client cannot trip the server's own reuse detection |
CI (GitHub Actions) runs lint (incl. jsx-a11y), typecheck, build and all three suites against a
Postgres service on every push and PR, enforcing a coverage threshold — scoped to the request-handling
surface on the server and to the logic layer (state, transport, error handling, validation) on the
client, since the UI is covered by the e2e suite rather than by unit tests. A separate job builds both
Docker images and boots the stack, so a broken Dockerfile or nginx config fails CI instead of landing
on a green main.
Auth code is easy to make look right. These are the attacks the design actually accounts for, and how. Each one has a test that fails if the defence is removed.
Nothing they can use directly — that is the whole point of how things are stored.
| Stored | As | Why |
|---|---|---|
| Password | bcrypt, cost 12 | Cost is the only dial between a ~240ms login and an offline crack. Legacy hashes are transparently upgraded on the next successful login. |
| Refresh token | SHA-256 digest | The raw value is a bearer credential. Storing it means a leak hands over every live session, no cracking required. A fast hash is right here (unlike passwords, there is no low-entropy secret to brute-force). |
| TOTP secret | AES-256-GCM | A symmetric credential: anyone holding it mints valid codes forever. In plaintext, 2FA protects against nothing that matters — least of all a database compromise. |
| Reset / email-change token | SHA-256 digest, 1h TTL | Otherwise a read-only leak is a password reset for every account. |
| Activation token | SHA-256 digest | Otherwise the leak is a way to verify — and so take over — any pending account. The route accepts the raw token; only its digest is stored. |
| Backup codes | SHA-256 digest of an 80-bit value | Shown once, at enrolment. Single use. 80 bits keeps the fast hash from being brute-forced out of a leak — the same reason a fast hash is fine for the high-entropy tokens above. |
Every token carries a typ claim, and each validator accepts only its own kind. Without that, the
pre-auth token is a full session: it is signed with JWT_ACCESS_SECRET and handed out after
the password but before the TOTP code, so replaying it as the accessToken cookie sails through
signature verification and skips 2FA entirely. httpOnly is no defence — DevTools shows and edits
those cookies. jwt.verify also pins algorithms: ['HS256'], closing the alg-confusion family.
OAuth links to an existing local account only if that account is verified. Merging on a bare email match is the Classic-Federated Merge attack: the attacker registers with the victim's address and never verifies it; the victim later signs in with GitHub, and their identity is welded onto an account whose password the attacker knows. Both sides must have proven control of the address — the provider vouches for it, and activation required clicking a link sent to that same inbox.
- Not via OAuth — the GitHub handshake earns a pre-auth token, exactly what a correct password earns. The session comes after the code.
- Not by replay — the last accepted TOTP time-step is recorded, so a code glimpsed within its window is dead on arrival.
- Not by turning it off — disabling 2FA requires the second factor itself. A password alone is precisely what 2FA exists to backstop.
- Not by re-enrolling —
setupis refused while 2FA is on (it used to silently rotate the secret and lock the user out). - Not by brute force — six digits is a million guesses;
/2fa/verifyallows five per 15 minutes, keyed on the account named by the pre-auth token (not the IP), so rotating IPs does not buy fresh attempts.
Login returns one message for "no such email" and "wrong password", and runs a bcrypt comparison
against a dummy hash when the user does not exist — otherwise the fast path is a timing oracle that
lists your registered users. Password reset always answers 200.
Registration is the deliberate exception. It creates the account and logs the user in in one
step, so it cannot return an identical answer for a taken and a free address without dropping that
instant-session flow. It no longer echoes the address back in the error, but the status still
distinguishes taken from free — a residual oracle, throttled by the same authLimiter (10 attempts
per 15 minutes) as login. Closing it fully would mean a register-then-verify-by-email flow with no
session until the link is clicked; that is a product decision, called out here rather than hidden.
- Rate limits are in-memory. Fine for one instance; a real multi-instance rollout needs a shared store (
rate-limit-redis) or the limit is per-process. - Access-token revocation lags by its lifetime. Revoking a session or changing the password kills the refresh token at once, but the stateless access token stays valid until it expires (15 min). Checking a session row on every request would trade that window for a database hit on every call; at this size the short access lifetime is the mitigation.
- No device fingerprinting or anomaly detection. Sessions are listed and revocable, which is the honest floor.
- The
/api/docsSwagger UI is public. Right for a demo, wrong for production.
- Vite over Create React App — CRA is deprecated and does not support React 19.
- httpOnly cookies over
localStorage— removes the XSS token-theft vector. This adds a CSRF surface, closed withSameSite=Strictplus a double-submit CSRF token (a non-httpOnlycsrfTokencookie echoed back in theX-CSRF-Tokenheader and verified on every mutating request) — defence in depth for those requests, and the part that would still hold in a cross-origin (SameSite=None) deployment./refreshis intentionally a safeGET, exempt from the token: the double-submit has a bootstrap gap (the very first request from a fresh browser carries nocsrfTokencookie yet), and a forced refresh can at worst log a user out, never act as them. - CSRF and rate limiting are parameters, not
NODE_ENVchecks — both used to be skipped wholesale underNODE_ENV=test, which meant two headline defences shipped with zero coverage. A control that switches itself off in tests is a control nobody tests. - Single-flight token refresh, coordinated across tabs — the server drops all sessions on a replayed refresh token, so N concurrent 401s each firing their own
/refreshwould trip its own theft detection. Within a tab every 401 awaits one shared promise; across tabs the refresh is serialised with the Web Locks API, so opening the app in two tabs at once no longer logs the user out. The bootstrap session check goes through the same path. (Web Locks needs a secure context — HTTPS or localhost; served over plain HTTP off localhost the cross-tab guard degrades to per-tab, which is why the app is meant to run behind TLS.) - Reuse detection distinguishes theft from revocation — a deliberately-ended session (logout, revoke, "log out other devices") is tombstoned rather than deleted, so when its dead token is next presented the server rejects that one request instead of treating it as a replay and dropping every session. Only a genuinely rotated-away token — indistinguishable from a stolen one — triggers the drop-everything response.
- The Query cache is cleared on logout — it outlives the session (it sits above the router), so without an explicit
clear()the next user to sign in on a shared browser would be served the previous user's cached notes, settings and sessions until each query refetched. - Zustand + a discriminated union — auth state is
checking | guest | awaitingTwoFactor | authenticated, so "authenticated but awaiting a second factor" is unrepresentable rather than merely unlikely, anduseris only reachable where it exists. - Server state in TanStack Query, client state in Zustand — the auth session is a client-owned state machine (above), but notes, the feed, sessions and settings are server state: fetched, cached, invalidated on mutation. Query owns those, so there are no hand-rolled
loading/error/datatriples and one obvious rule for where a piece of state lives. - Prisma driver adapter (v7) — Prisma 7 connects through a driver adapter (
@prisma/adapter-pg); the connection string lives inprisma.config.ts, not the schema. createApp()factory — separates app wiring fromlisten(), enabling supertest integration tests without a live port. CSRF and rate-limit enforcement are constructor parameters, so the security tests drive the real controls instead of a copy.- The request boundary is typed, not asserted —
validateBody(schema)is generic and each handler is aBodyHandler<XInput>, soreq.bodyis the Zod-inferred shape at every call site. Rename a field in a schema and the controller stops compiling, instead of areq.body as { … }assertion drifting until it 500s at runtime. - One JSON error contract, localised by code — every failure comes back as
{ message, code?, errors? }: an unknown route is a JSON 404, a malformed body a 400, a Prisma unique violation a 409 — never an HTML page or a 500 for a client mistake. The common user-facing errors carry a stablecode, which the client maps to localised copy, so the Spanish and German UIs stop rendering English server strings. - A production image that ships only production — the runtime Docker stage installs with
--omit=devand copies just the compileddistand the generated Prisma client, sovitest,oxlint,tsxand the@typestree never reach the container; it runs as a non-root user and applies migrations on start. - Security headers on the SPA, not just the API —
helmetonly rides on/apiresponses, so nginx sets a Content-Security-Policy (and the usual header set) on the document the browser actually executes, and long-caches the hashed assets while keepingindex.htmluncached. The direct API port used for Swagger is bound to loopback, so it cannot be used to forgeX-Forwarded-Forpast the rate limiter. - Accessibility is verified, not claimed — the
jsx-a11ygate fails the build on a violation (--deny-warnings) and the E2E suite runs axe-core (WCAG 2.1 AA) against the primary pages, so the "accessible" claim is something a reviewer re-runs rather than takes on faith. COOKIE_SECUREdecoupled fromNODE_ENV— behind a TLS-terminating proxy the app sees HTTP while cookies must still beSecure.- Owner-only resource access — note mutations verify ownership and return
404(not403) for someone else's note, so the API never leaks which ids exist. - A native
<select>for the language switcher — the hand-rolled listbox had no keyboard support. Reimplementing arrow keys, typeahead and focus management to arrive back where the platform already is, is not a good trade.





