https://nextjs-security-lesson.vercel.app/
This repo is a pragmatic Next.js 15 + React + TypeScript training ground with a bias for security and maintainability. Expect strict types, Tailwind for velocity, and guardrails everywhere: least-privilege CI, CodeQL scanning, and curated Dependabot updates. Each pull request gets a clean install, typecheck, and build; Vercel previews make review tactile. Contributions are welcome—small, focused PRs with clear intent. The goal: a nimble codebase with professional hygiene, not ceremony for ceremony’s sake.
A production-shaped, pedagogy-first Next.js (App Router) project that teaches secure login flows, JWT sessions, CSRF protection, RBAC, and per-directory ACLs — all with Tailwind and TypeScript. Minimal by design, but the edges (cookies, middleware, redirects) are treated seriously so you can learn the right habits.
Built collaboratively by Dude and GPT-5 Thinking (your friendly “Dudeness” AI mentor). 👋
- Credential auth with bcrypt (JSON user store in dev)
- JWT in httpOnly cookie (
__Host-session) and App Router middleware guard - CSRF with the double-submit pattern (
__Host-csrf+ hidden input) - PRG redirects: every POST ends with 303 See Other (no “POST /protected” ghosts)
- RBAC:
roleclaim in JWT gates/admin - Per-directory ACL for
/docs/*paths (most-specific prefix wins) - Strict headers + SameSite=Lax + no inline styles (Tailwind only)
- Next.js 15 (App Router, Route Handlers, Middleware)
- TypeScript
- Tailwind CSS (no global utility classes; all Tailwind)
- jose for JWTs
- bcryptjs for password hashing
- Optional: Upstash (or any store) if you wire rate limiting (not required to run)
# 1) Install
npm i
# 2) Create env file
cp .env.local.example .env.local
# (edit values — see section below)
# 3) Seed a user (dev only, see "Seeding users")
# Option A: npm script if present
# Option B: hand-edit data/users.json from the example below
# 4) Run
npm run dev
# Build and preview (optional)
npm run build && npm run startCreate .env.local:
# Required
SESSION_SECRET=change-me-to-a-long-random-string
SESSION_VERSION=1
BCRYPT_ROUNDS=12
# Optional (only if you wired a remote store like Upstash for rate limiting)
# UPSTASH_REDIS_REST_URL=https://<your-upstash-url>
# UPSTASH_REDIS_REST_TOKEN=<your-upstash-token>Tip: Bump
SESSION_VERSIONto immediately invalidate all sessions.
-
Development
- JSON store writes are enabled: you can “Add user” (admin) and “Change password” (account).
- Dev-only ACL debug panel explains why access was denied.
-
Production
- JSON store writes are disabled: those UIs show a read-only note.
- ACL denials return 404 (no information leak).
- Security headers are active; cookies are
Secure/HttpOnlywithSameSite=Lax.
The header includes an environment strip, and the homepage explains the differences.
GET /– Tour guide (explains features, links everywhere)GET /login– Credential sign-in form (CSRF hidden field)POST /api/login– Authenticates, mints JWT, sets__Host-session, 303 →/protectedPOST /api/logout– Clears session cookie, 303 →/api/csrf→/loginGET /api/csrf– Mints__Host-csrftoken cookie and redirects to/loginGET /protected– User page; requires valid JWTGET /admin– Admin-only (JWTrole === "admin")GET /account– Change password (dev only)POST /api/account/change-password– Update password (dev only)GET /docs– Docs indexGET /docs/[...slug]– Any docs subpath; ACL-controlled- Middleware gates:
/protected/*,/admin/*,/account/*,/docs/*
-
JWT session cookie:
__Host-session,HttpOnly,Secure,SameSite=Lax, short TTL- Claims:
sub,username,role,v(session version)
-
CSRF:
- Double-submit token (
__Host-csrfcookie + hidden form field) SameSite=Laxmitigates most drive-by POSTs; we still enforce CSRF for defense-in-depth- Optional Origin check added to POST routes
- Double-submit token (
-
PRG 303: Every POST ends with a 303 See Other (classic Post-Redirect-Get)
-
RBAC & ACL:
- RBAC: role-gated
/admin - ACL: path-prefix rules for
/docs/*, most-specific wins, admins see everything
- RBAC: role-gated
-
Headers & caching:
Cache-Control: private, no-storeon protected responses- Strict security headers (CSP/HSTS/XFO/referrer/permissions) via Next config
-
Rate limiting (login): local or store-backed; tiny random delay on failures
This is a teaching app, not “unhackable.” It aims to model sane defaults and clean seams so you can evolve it safely.
- Training-wheels login → fix insecure flows, add CSRF, SameSite, rate limit, and 303 PRG
- Real identity (file-based) → JSON users, bcrypt hashes, JWT cookie (
sub/username/role/v), middleware verification - RBAC + protected routes →
/protected&/admingates; clean logout - Directory ACLs → rule file guards
/docs/*(most-specific wins), dev-only “why blocked?” panel - Account management (dev) → change password (with confirm), admin “add user” — dev-only writes; prod read-only notes
- App Router, Route Handlers (no Server Actions)
- Middleware verifies JWT, sets cache headers, enforces RBAC & ACL
- Stateless sessions: short-lived JWT; bump
SESSION_VERSIONto revoke globally - Tailwind everywhere: no custom utility classes, no inline styles
acl.config.ts (example):
export const ACL = [
// Only u1 and u2 (and admins) can see /docs/project-a/*
{ pathPrefix: "/docs/project-a", allow: { userIds: ["u1", "u2"], roles: ["admin"] } },
// Only u1 (and admins) can see /docs/finance/q4/*
{ pathPrefix: "/docs/finance/q4", allow: { userIds: ["u1"], roles: ["admin"] } },
// Default for /docs/*
{ pathPrefix: "/docs", allow: { roles: ["admin"] } },
];Notes:
- Most-specific rule wins (longest matching
pathPrefix) - You can allow by
roles,userIds(JWTsub), and optionallyusernames
Create data/users.json (dev only) and put users like:
[
{
"id": "u1",
"username": "admin",
"role": "admin",
"passwordHash": "$2a$12$...your_bcrypt_hash..."
},
{
"id": "u2",
"username": "reader",
"role": "user",
"passwordHash": "$2a$12$...another_hash..."
}
]You can generate a hash in Node:
node -e "import('bcryptjs').then(b=>b.hash('SupaPassw0rd!',12).then(h=>console.log(h)))"Or use the included scripts/add-user.mjs (if present) to add users safely.
Production: the filesystem is read-only on Vercel; the app will not write to data/users.json in prod and will show read-only hints in the UI.
- All pages/components use Tailwind utility classes (no custom global classnames).
- Dark-mode variants are included (
dark:). SetdarkModeto"media"or"class"intailwind.config.js.
Ideas, critiques, PRs — all welcome. This repo is intentionally instructional; we value feedback on:
- Security ergonomics (CSRF, PRG, middleware matcher)
- ACL rule design and dev UX
- Next steps for a DB adapter (Prisma/Postgres)
- Documentation clarity for newcomers
Open an issue with “Suggestion:” in the title so we can triage fast.
MIT © Dude + GPT-5 Thinking
Attribution
This tutorial and code were co-created by Dude and GPT-5 Thinking (the AI assistant in this repo’s issues and commits). The assistant focused on safe defaults, clear seams, and a calm, stepwise curriculum so others can learn without being overwhelmed.