Production: Sign in first; admins see everything. Users are gated by ACL rules under /docs/*.

Production mode: JSON-backed write operations are disabled. “Add user” (Admin) and “Change password” (Account) show read-only hints. All routes are enforced by middleware (JWT + RBAC + ACL) with PRG 303 redirects and CSRF.

Source code available on GitHub — audit, fork, improve the lessons.

A step-by-step, production-shaped walkthrough of building safer auth and access controls with the App Router and Tailwind. The app is intentionally simple, but the edges — cookies, CSRF, middleware, and redirects — are treated like the real world.

ADMIN - Username: admin Password: password

USER - Username: reader Password: password

Explore

Sign in first; admins see everything. Users are gated by ACL rules under /docs/*.

What’s implemented

Auth model
Username + password (bcrypt), users in data/users.json (dev). Writes disabled in production.
Session
Signed JWT (HS256) in host-scoped cookie __Host-session, short expiry, verified in middleware with versioning.
CSRF
Double-submit token (__Host-csrf + hidden field) and SameSite=Lax.
Redirect hygiene
PRG flow with 303 See Other after POSTs (no POST /protected ghosts).
Access control
Middleware gates /protected, /admin, /account, and /docs. RBAC via JWT role. Per-directory ACL for /docs/* (most-specific prefix wins). Dev-only “NO_ACCESS” explainer on the protected page.
Hardening
HttpOnly/Secure cookies, strict cache control private, no-store, CSP/HSTS/nosniff/frame-ancestors, origin checks on POSTs, and login rate limiting with jitter.

How we got here (lesson timeline)

  1. Lesson 1 — Training-wheels login: fixed insecure flows; added CSRF, SameSite, rate limiting, and 303 PRG.
  2. Lesson 2 — Real identity (file-based): JSON users, bcrypt hashes, JWT cookie with sub / username / role / v; middleware verification.
  3. Lesson 3 — RBAC + protected routes: /protected & /admin gates, clean logout.
  4. Lesson 4 — Directory ACLs: rule file for /docs/* and dev-only “why blocked?” panel.
  5. Lesson 5 — Account management: change password (with confirm) and admin-only “add user.” Writes in dev; read-only in prod.

Architecture snapshot

Runtime
App Router (Next 15), route handlers (no Server Actions), Node runtime for bcrypt.
Perimeter
Middleware verifies JWT on every protected request, sets cache headers, and enforces RBAC & ACL.
State
Stateless JWT (short-lived). Global rotation via SESSION_VERSION.

What’s next (DB-ready path)

  • Swap the JSON repo for a Prisma/Postgres adapter, keeping the same interface (findUserByUsername / findById / createUser / updatePassword).
  • Keep JWT claims stable (sub / username / role / v) so middleware remains unchanged.
  • Optional: migrate ACL rules into a table; the helper API stays the same.