A hardened authentication starter for Next.js
Credentials, magic links, and OAuth2/OIDC with rate limiting, session caching, i18n,
and security hardening.
AuthForge started as a way to understand authentication and what a login system has to survive: timing attacks, account enumeration, brute-force attempts, race conditions, and silent misconfiguration. What began as a small Next.js + Auth.js experiment grew, session by session, into a reference implementation with a full test suite, CI/CD, and security measures.
- Features
- Tech Stack
- Screenshots
- Architecture
- Getting Started
- Docker
- Testing
- CI/CD
- Security
- Internationalization
- Project Structure
- Roadmap
- Contributing
- License
- Credentials sign-in: Email + password, with timing-safe comparison against a dummy hash so a wrong password and a nonexistent account take the same amount of time to reject.
- Passwordless magic-link sign-in: One-click email login via Auth.js's email provider, with the same enumeration protections as credentials.
- OAuth2/OIDC sign-in: GitHub and Google, with configurable dangerous-account-linking per provider.
- Signup: With strict validation (name, email, password strength) and duplicate-email detection.
- Forgot/reset password: Single-use, time-limited, hashed tokens; resetting a password atomically updates the password, invalidates the token, and revokes every existing session for that user in one database transaction.
- DB-backed sessions for every provider, including credentials: A custom JWT
encodeoverride gives credentials sign-ins a real, revocable, database session instead of a self-contained JWT.
Timing-safe checks, rate limiting, timing side-channel mitigation, atomic writes, and fail-fast environment validation are all first-class citizens here, not an afterthought.
See the Security section below for the full breakdown.
- 5 languages out of the box: English, Arabic, Spanish, Russian, Chinese (including RTL layout support for Arabic).
- Locale persisted via cookie, resolved server-side so there's no flash of the wrong language.
- Dark / light theme, persisted via cookie and resolved server-side.
- Built on Radix UI for accessible, consistent components.
- Every form field is fully validated with Zod + React Hook Form.
- 248 tests across 17 suites (Jest + React Testing Library) covering API routes, auth logic, and every UI component.
- TypeScript everywhere, strict mode, Zod-validated at every request boundary.
- ESLint + Prettier, enforced in CI.
- GitHub Actions CI: format check, lint, type check, full test suite, production build, and a dependency security audit, all on every pull request.
- Automated releases via Release Please: Conventional Commits in, changelog and versioned GitHub Releases out.
- Dockerized for both development (hot reload) and production (multi-stage, non-root, standalone Next.js output).
|
Next.js |
React |
TypeScript |
Tailwind CSS |
Radix UI |
Auth.js |
|
Prisma |
SQLite |
Redis |
Docker |
Jest |
Testing Lib |
Also in the mix: Zod, React Hook Form, TanStack Query, Axios, Nodemailer + React Email, rate-limiter-flexible, ESLint & Prettier, Husky + lint-staged, and Testing Library.
flowchart TD
subgraph Client["Client"]
UI["<b>App Router UI</b><br />Radix UI, i18n, React Hook Form + Zod"]
end
subgraph Server["Server"]
RL{{"<b>Rate Limiter</b><br />per IP + per email"}}
Reject["Too Many Requests"]
Routes["<b>API Routes</b><br />signup, forgot/reset password"]
AuthJS["<b>Auth.js</b><br />credentials, magic link, OAuth2/OIDC"]
end
subgraph External["External Services"]
Email["<b>Email / SMTP</b><br />magic links and reset tokens"]
OAuth["<b>OAuth2/OIDC Providers</b><br />Google and GitHub"]
end
subgraph Cache["Cache"]
Redis[("<b>Redis</b><br />rate-limit counters and session cache")]
end
subgraph DB["Database"]
SQLite[("<b>SQLite</b> via Prisma<br />users Β· accounts Β· tokens")]
end
UI -->|"form POST, signIn() / signOut()"| RL
RL -->|"over limit"| Reject
RL -->|"allowed"| Routes
RL -->|"allowed"| AuthJS
RL -->|"INCR + TTL (fail-open)"| Redis
Routes --->|"create user or reset token"| SQLite
Routes -->|"send reset link"| Email
AuthJS -->|"send magic link"| Email
AuthJS -->|"redirect / callback"| OAuth
AuthJS -.->|"session cache"| Redis
AuthJS --->|"users Β· accounts Β· tokens"| SQLite
classDef client fill:#e0f2fe,stroke:#0284c7,color:#0c4a6e
classDef server fill:#fef9c3,stroke:#ca8a04,color:#713f12
classDef gate fill:#ffedd5,stroke:#ea580c,color:#7c2d12,stroke-width:2px
classDef danger fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
classDef data fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef external fill:#f3e8ff,stroke:#9333ea,color:#581c87
class UI client
class Routes,AuthJS server
class RL gate
class Reject danger
class SQLite,Redis data
class OAuth,Email external
style Client fill:#f8fafc,stroke:#94a3b8
style Server fill:#f8fafc,stroke:#94a3b8
style External fill:#f8fafc,stroke:#94a3b8
style Cache fill:#f8fafc,stroke:#94a3b8
style DB fill:#f8fafc,stroke:#94a3b8
Every sensitive endpoint checks rate limits before touching the database, every password-changing operation is wrapped in a transaction, and session reads are Redis-cached with automatic invalidation on write, falling back to the database (and failing open on rate limiting) if Redis is unavailable.
- Node.js
>= 20.9.0 - A Node.js package manager (npm, Yarn, pnpm, Bun, ...)
- Docker (recommended) or a local Redis instance if running manually
- An SMTP relay with OAuth2 (e.g. Gmail) for magic-link and password-reset emails
- OAuth app credentials from GitHub and/or Google Cloud Console
git clone https://github.com/redouane-nouri/AuthForge.git
cd AuthForge
cp .env.example .env
# fill in .env (see Environment Variables below)
docker compose -f docker-compose.dev.yml up --buildThis builds the app, starts a Redis container alongside it, runs pending Prisma migrations
automatically, and serves the app at http://localhost:3000 (configurable via NEXT_PUBLIC_URL env var) with hot
reload.
Commands below use npm; swap in yarn, pnpm, or bun as you prefer.
git clone https://github.com/redouane-nouri/AuthForge.git
cd AuthForge
npm install
cp .env.example .env
# fill in .env (see Environment Variables below)
npx prisma generate
npx prisma migrate dev
npm run devAll variables live in .env (see .env.example for a ready-to-copy template).
| Variable | Description |
|---|---|
NEXT_PUBLIC_URL |
Public base URL of the app (e.g. http://localhost:3000) |
NEXT_PUBLIC_AXIOS_BASEPATH |
Base path the client-side API calls are made against (/api/v1) |
NEXT_TELEMETRY_DISABLED |
Set to 1 to opt out of Next.js's anonymous usage telemetry |
DB_URL |
Prisma/SQLite connection string |
REDIS_URL |
Redis connection string (rate limiting + session cache) |
BCRYPT_HASH_ROUNDS |
bcrypt cost factor (integer, 4-31) |
AUTH_SECRET |
Auth.js signing secret (generate one with openssl rand -base64 33) |
AUTH_BASEPATH |
Base path for Auth.js's own endpoints (/api/v1/auth) |
AUTH_TRUST_HOST |
Trust the request's Host header (needed behind most reverse proxies) |
AUTH_URL |
Canonical Auth.js callback URL |
AUTH_GITHUB_ID / AUTH_GITHUB_SECRET |
GitHub OAuth app credentials |
AUTH_ALLOW_GITHUB_DANGEROUS_EMAIL_ACCOUNT_LINKING |
Allow linking a GitHub sign-in to an existing account by email |
AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET |
Google OAuth app credentials |
AUTH_ALLOW_GOOGLE_DANGEROUS_EMAIL_ACCOUNT_LINKING |
Allow linking a Google sign-in to an existing account by email |
EMAIL_SERVER_HOST / EMAIL_SERVER_PORT / EMAIL_SERVER_SECURE |
SMTP server connection details |
EMAIL_SERVER_AUTH_USER |
Mailbox address used to send email |
EMAIL_SERVER_AUTH_CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN |
OAuth2 credentials for the mailbox |
EMAIL_FROM |
"From" header on outgoing emails |
Every one of these is validated on startup, an unset or malformed value fails immediately with a clear error naming the exact variable, instead of surfacing as a cryptic failure deep in some unrelated code path later.
Two Compose files, two targets of the same multi-stage Dockerfile:
# Development: hot reload, bundled Redis, source mounted as a volume
docker compose -f docker-compose.dev.yml up --build
# Production: standalone Next.js build, non-root user, persistent SQLite + Redis volumes
docker compose -f docker-compose.prod.yml up --build -dIn both cases, docker-entrypoint.sh runs prisma migrate deploy before starting the server, so the
database schema is always up to date on container start.
npm run test # run the full suite once (or yarn/pnpm/bun test)
npm run test:watch # watch mode248 tests across 17 suites, covering every API route, the Auth.js configuration itself, and every UI component.
ci.yml: on every pull request intomain, dependency audit, format check, lint, type check, the full test suite, and a production build. Nothing merges without all of that passing.release-please.yml: on every merge intomain, Release Please reads Conventional Commits and maintains a standing "release PR" with the changelog and version bump.
- Content-Security-Policy + security headers on every response:
X-Frame-Options,X-Content-Type-Options,Referrer-Policy, and HSTS, configured innext.config.ts. - Timing-safe credential checks: A login attempt for a nonexistent user still runs a full
bcrypt.compare()against a precomputed dummy hash, so response time can't be used to determine whether an email is registered. - Deferred processing to close timing side-channels: The magic-link and forgot-password flows
defer the actual user lookup and email send until after the HTTP response is already sent (via
Next's
after()), so the response latency itself never reveals whether an account exists. - Rate limiting on every sensitive endpoint: Signup, credentials sign-in, magic-link sign-in, and forgot/reset password are all limited per IP and per email independently, backed by Redis, and designed to fail open (a Redis outage degrades to "no rate limiting," not "the app is down").
- Atomic password resets: Resetting a password runs inside a single database transaction for password update, verification-token deletion, and revocation of every existing session for that user all commit together or not at all, with the Redis session cache invalidated afterward.
- Strict, typed request validation: Every API route parses its body through a Zod schema in
.strict()mode, rejecting unexpected fields outright. - Fail-fast environment validation: every required environment variable is read through a
dedicated, validated getter (
getXFromEnv()) that throws a precise error at startup if it's missing or malformed, rather than letting it silently becomeNaN/undefinedand fail mysteriously later, often in production, often far from the actual cause.
Powered by next-intl, with the locale resolved server-side from a cookie
(no flash of the wrong language) and persisted client-side on change. en is the default locale,
and ar renders a full RTL layout.
| Language | Code |
|---|---|
| English | en |
| Arabic | ar |
| Spanish | es |
| Russian | ru |
| Chinese | zh |
.
βββ app/ # Next.js App Router
β βββ api/v1/auth/ # Signup, forgot/reset password, and the Auth.js catch-all route
β βββ connect/ # Combined sign in / sign up page
β βββ forgot-password/
β βββ reset-password/
βββ components/ # UI, grouped by feature (auth, signin, signup, forgotPassword, resetPassword, connect, layout, home)
βββ lib/
β βββ auth/ # Auth.js configuration and helpers
β βββ axios/ # Shared Axios client instance
β βββ prisma/ # Prisma client singleton
β βββ redis/ # Redis client singleton + session cache
β βββ rateLimiter/ # Per-IP / per-email rate limiters
β βββ mailer/ # Shared nodemailer transporter
β βββ i18n/ # next-intl request config
βββ messages/ # Translation files (en, ar, es, ru, zh)
βββ prisma/ # Schema + migrations (SQLite)
βββ utils/ # Shared functions, constants, enums, types
βββ .github/workflows/ # CI and automated releases
- Passkey/WebAuthn support
- Two-factor authentication (TOTP)
- Postgres support alongside SQLite
Issues and pull requests are welcome. Commit messages follow
Conventional Commits (feat:, fix:, test:, refactor:,
...). This is what drives the automated changelog and versioning, so it's not just a style
preference.
Git hooks (via Husky) enforce the same checks CI runs, so issues get caught locally instead of after a push:
- pre-commit: runs lint-staged, which formats and lints only the files you've staged.
- pre-push: runs the full
format:check && lint && tsc --noEmit && testsequence once, right before the push goes out.
Both are installed automatically the first time you install dependencies (via the prepare
script).
Distributed under the MIT License.













