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:
role
claim 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 start
Create .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_VERSION
to 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
/HttpOnly
withSameSite=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 →/protected
POST /api/logout
– Clears session cookie, 303 →/api/csrf
→/login
GET /api/csrf
– Mints__Host-csrf
token cookie and redirects to/login
GET /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-csrf
cookie + hidden form field) SameSite=Lax
mitigates 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-store
on 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
&/admin
gates; 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_VERSION
to 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:
). SetdarkMode
to"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.