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
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) andSameSite=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 JWTrole
. 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)
- Lesson 1 — Training-wheels login: fixed insecure flows; added CSRF, SameSite, rate limiting, and 303 PRG.
- Lesson 2 — Real identity (file-based): JSON users, bcrypt hashes, JWT cookie with
sub / username / role / v
; middleware verification. - Lesson 3 — RBAC + protected routes:
/protected
&/admin
gates, clean logout. - Lesson 4 — Directory ACLs: rule file for
/docs/*
and dev-only “why blocked?” panel. - 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.