Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Full-Stack JWT Auth

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.

CI License: MIT TypeScript React Express PostgreSQL

The API and the SPA are two independent apps (/server, /client) that share only an HTTP contract.


Screenshots

Sign in — password or GitHub Create account — live password checklist
Feed — other verified users' public posts My notes — owner-only CRUD
Two-factor enrolment — QR + code Settings — 2FA, sessions, danger zone

Features

  • 🗒️ 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=Strict cookies (no tokens in localStorage).
  • 🔄 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.
  • 🛡️ Hardeninghelmet on 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 no Cookie header 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-field aria-invalid errors with focus moved to the first invalid field, <output>/live-region announcements, a skip link and a focused <main> on navigation. jsx-a11y lint 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 test from a clean clone.
  • 🐳 One-command stackdocker compose up brings up PostgreSQL + API + client.

Tech stack

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

Architecture

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]
Loading

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.

Token flow

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
Loading

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.


Getting started

Option A — Docker (everything, one command)

docker compose up --build
# client → http://localhost:8080   api → http://localhost:5050

Spins up PostgreSQL, runs migrations, and serves the API and the built client (nginx).

Option B — local dev

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)

Verifying an account (email activation)

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` output

The 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.

Signing in with GitHub (OAuth)

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:

  1. GitHub → Settings → Developer settings → OAuth Apps → New OAuth App.

  2. Set Authorization callback URL to http://localhost:3000/api/auth/github/callback — the client origin, because that is where the session cookies must land.

  3. Copy the Client ID, generate a Client secret, and add both to server/.env:

    GITHUB_CLIENT_ID=...
    GITHUB_CLIENT_SECRET=...
  4. 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.


Environment variables (server)

Variable Required Notes
DATABASE_URL PostgreSQL connection string
JWT_ACCESS_SECRET / JWT_REFRESH_SECRET ≥ 32 chars and must differopenssl 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_PROXY matters 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 blanket true and a client can forge X-Forwarded-For to get a fresh bucket per request. It must equal the actual number of hops: 0 running directly, 1 behind one nginx/load balancer.


API reference

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

Testing

# 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 tests

The 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.


Threat model

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.

If the database leaks, what does the attacker get?

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.

Token confusion

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.

Account linking (pre-account hijacking)

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.

2FA that cannot be walked around

  • 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-enrollingsetup is 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/verify allows 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.

Enumeration

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.

What is deliberately not solved

  • 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/docs Swagger UI is public. Right for a demo, wrong for production.

Design decisions & trade-offs

  • 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 with SameSite=Strict plus a double-submit CSRF token (a non-httpOnly csrfToken cookie echoed back in the X-CSRF-Token header 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. /refresh is intentionally a safe GET, exempt from the token: the double-submit has a bootstrap gap (the very first request from a fresh browser carries no csrfToken cookie yet), and a forced refresh can at worst log a user out, never act as them.
  • CSRF and rate limiting are parameters, not NODE_ENV checks — both used to be skipped wholesale under NODE_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 /refresh would 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, and user is 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/data triples 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 in prisma.config.ts, not the schema.
  • createApp() factory — separates app wiring from listen(), 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 assertedvalidateBody(schema) is generic and each handler is a BodyHandler<XInput>, so req.body is the Zod-inferred shape at every call site. Rename a field in a schema and the controller stops compiling, instead of a req.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 stable code, 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=dev and copies just the compiled dist and the generated Prisma client, so vitest, oxlint, tsx and the @types tree never reach the container; it runs as a non-root user and applies migrations on start.
  • Security headers on the SPA, not just the APIhelmet only rides on /api responses, 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 keeping index.html uncached. The direct API port used for Swagger is bound to loopback, so it cannot be used to forge X-Forwarded-For past the rate limiter.
  • Accessibility is verified, not claimed — the jsx-a11y gate 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_SECURE decoupled from NODE_ENV — behind a TLS-terminating proxy the app sees HTTP while cookies must still be Secure.
  • Owner-only resource access — note mutations verify ownership and return 404 (not 403) 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.

License

MIT

About

Full-stack auth demo (React + Express/Prisma): sessions, JWT refresh, 2FA/TOTP, GitHub OAuth, rate limiting

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages