Complete JWT authentication system with refresh token rotation, RBAC, and zero XSS risk
npx create-nestjs-auth@latestInteractive scaffolding tool that sets up this entire boilerplate in a few steps. Configure database, JWT secrets, and dependencies automatically.
- Problem: Building secure JWT auth with refresh tokens in NestJS takes 40+ hours and requires deep security knowledge.
- Solution: This gives you a production-ready auth system in 3 minutes—tested patterns, no security holes.
| Feature | This Boilerplate | Passport JWT | NestJS Auth Samples | Custom JWT | Auth0/Clerk |
|---|---|---|---|---|---|
| Setup Time | 3 minutes | 2-4 hours | 4-6 hours | 40+ hours | 1-2 hours |
| Refresh Token Rotation | Yes - Auto-rotation | No - Manual | No - Manual | No - Manual | Yes - Built-in |
| Multi-Device Sessions | Yes - 5 devices/user | No | No | No - Manual | Yes - Built-in |
| HttpOnly Cookie Auth | Yes - Zero XSS risk | Manual setup | Manual setup | No - Manual | Yes - Built-in |
| RBAC Guards | Yes - @Roles() decorator |
Manual guards | Basic example | No - DIY | Yes - Built-in |
| Rate Limiting | Yes - Configured | No - Manual | No | No - Manual | Yes - Built-in |
| Structured Logging | Yes - Pino + PII redaction | No - Console | No | No - Manual | Yes - Built-in |
| Database Integration | Yes - Prisma + PostgreSQL | Your choice | TypeORM example | No - DIY | Managed |
| Token Invalidation | Yes - DB-backed | No - Stateless only | No | No - Manual | Yes - Built-in |
| Brute-Force Protection | Yes - 5 attempts/min | No - Manual | No | No - Manual | Yes - Built-in |
| Health Checks | Yes - K8s-ready probes | No - Manual | No | No - Manual | N/A |
| Password Validation | Yes - Regex + bcrypt 12 | Basic | No | No - Manual | Yes - Built-in |
| E2E Tests | Yes - Included | No - Manual | No | No - Manual | API tests |
| Self-Hosted | Yes - Free | Yes - Free | Yes - Free | Yes - Free | $25/mo+ |
| No Vendor Lock-in | Yes - Full control | Yes - Full control | Yes - Full control | Yes - Full control | No - Locked |
| Production-Ready | Yes - Day 1 | Needs work | No - Example only | No - Needs testing | Yes - Enterprise |
| Solution | Setup | Security Hardening | Testing | Maintenance | Total |
|---|---|---|---|---|---|
| This Boilerplate | 3 min | Done | Done | Minimal | 3 min |
| Passport JWT | 2h | 8h (refresh, cookies) | 4h | Medium | 14h |
| Custom JWT | 6h | 20h (all features) | 10h | High | 36h+ |
| Auth0/Clerk | 1h | Done | 2h | Vendor dependency | 3h + $$$ |
Verdict: Use this if you need production-grade auth without the 40-hour investment or monthly SaaS fees.
- Token rotation: Refresh tokens auto-rotate on each use—stolen tokens die immediately
- Zero XSS risk: HttpOnly cookies only—no localStorage, no client-side token access
- RBAC in 2 lines: Add
@Roles(UserRole.ADMIN)to any endpoint—done - Multi-device sessions: Track 5 devices per user with automatic cleanup
- Brute-force protection: Rate limiting: 5 auth attempts/minute, 10 requests/minute globally
- PII-safe logs: Pino structured logging—passwords/tokens auto-redacted
- Bcrypt 12 rounds: Industry-standard password hashing (2025 security baseline)
Tech Stack: NestJS 11.0, Prisma 6.19, PostgreSQL, Pino, Zod validation
- Node.js >= 20.x
- PostgreSQL >= 16.x
- npm >= 10.x
git clone https://github.com/masabinhok/nestjs-jwt-rbac-boilerplate.git
cd nestjs-jwt-rbac-boilerplate/api
npm installcp .env.example .envGenerate secrets and edit .env:
# Generate JWT secrets
openssl rand -base64 32DATABASE_URL="postgresql://postgres:postgres@localhost:5432/nest_auth_db"
JWT_ACCESS_SECRET="generated-secret-min-32-chars"
JWT_REFRESH_SECRET="another-generated-secret-min-32-chars"
JWT_ACCESS_EXPIRY="60m"
JWT_REFRESH_EXPIRY="30d"
NODE_ENV="development"
PORT="8080"
CORS_ORIGIN="http://localhost:3000"
LOG_LEVEL="info"npm run prisma:generate && npm run prisma:migrate && npm run prisma:seedDefault credentials: [email protected] / Admin@123
npm run start:devVerify it works:
curl http://localhost:8080/api/v1/healthExpected: {"status":"ok","info":{"database":{"status":"up"}}...}
Base URL: http://localhost:8080/api/v1
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
| POST | /auth/signup |
Register new user | No |
| POST | /auth/login |
Login with credentials | No |
| POST | /auth/refresh |
Refresh access token | Yes (refresh token) |
| POST | /auth/logout |
Logout and invalidate tokens | Yes |
| GET | /auth/me |
Get current user info | Yes |
Request:
{
"email": "[email protected]",
"password": "SecurePass@123",
"fullName": "John Doe"
}Response 201:
{
"user": {
"id": "cm3k5j8l90000xyz",
"email": "[email protected]",
"fullName": "John Doe",
"role": "USER"
},
"message": "User registered successfully"
}curl:
curl -X POST http://localhost:8080/api/v1/auth/signup \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"SecurePass@123","fullName":"Test User"}'Request:
{
"email": "[email protected]",
"password": "Admin@123"
}Response 200:
{
"user": {
"id": "cm3k5j8l90000xyz",
"email": "[email protected]",
"fullName": "Admin User",
"role": "ADMIN"
}
}Sets cookies: accessToken (httpOnly, 1h), refreshToken (httpOnly, 30d)
curl:
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-c cookies.txt \
-d '{"email":"[email protected]","password":"Admin@123"}'Request: No body (uses refreshToken cookie)
Response 200:
{
"message": "Tokens refreshed successfully"
}curl:
curl -X POST http://localhost:8080/api/v1/auth/refresh \
-b cookies.txt \
-c cookies.txtRequest: No body
Response 200:
{
"message": "Logged out successfully"
}curl:
curl -X POST http://localhost:8080/api/v1/auth/logout -b cookies.txtRequest: No body (uses accessToken cookie)
Response 200:
{
"id": "cm3k5j8l90000xyz",
"email": "[email protected]",
"fullName": "Admin User",
"role": "ADMIN",
"isActive": true,
"createdAt": "2025-11-16T10:30:00.000Z",
"updatedAt": "2025-11-16T10:30:00.000Z"
}curl:
curl -X GET http://localhost:8080/api/v1/auth/me -b cookies.txt| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
| GET | /users/profile |
Get own profile | Yes |
| PATCH | /users/profile |
Update own profile | Yes |
Response 200:
{
"id": "cm3k5j8l90000xyz",
"email": "[email protected]",
"fullName": "John Doe",
"role": "USER",
"isActive": true,
"createdAt": "2025-11-16T10:30:00.000Z",
"updatedAt": "2025-11-16T10:30:00.000Z"
}curl:
curl -X GET http://localhost:8080/api/v1/users/profile -b cookies.txtRequest:
{
"fullName": "John Smith"
}Response 200:
{
"id": "cm3k5j8l90000xyz",
"email": "[email protected]",
"fullName": "John Smith",
"role": "USER",
"isActive": true,
"createdAt": "2025-11-16T10:30:00.000Z",
"updatedAt": "2025-11-16T10:30:00.000Z"
}curl:
curl -X PATCH http://localhost:8080/api/v1/users/profile \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"fullName":"John Smith"}'Requires ADMIN role. Returns 403 for non-admin users.
| Method | Endpoint | Description | Auth Required |
|---|---|---|---|
| GET | /users |
List all users (paginated) | ADMIN |
| GET | /users/:id |
Get user by ID | ADMIN |
| PATCH | /users/:id |
Update user | ADMIN |
| DELETE | /users/:id |
Soft delete user | ADMIN |
Query params: page (default: 1), limit (default: 10, max: 100)
Response 200:
{
"data": [
{
"id": "cm3k5j8l90000xyz",
"email": "[email protected]",
"fullName": "User One",
"role": "USER",
"isActive": true,
"createdAt": "2025-11-16T10:30:00.000Z",
"updatedAt": "2025-11-16T10:30:00.000Z"
}
],
"meta": {
"total": 150,
"page": 1,
"limit": 10,
"totalPages": 15,
"hasNext": true,
"hasPrevious": false
}
}curl:
curl -X GET "http://localhost:8080/api/v1/users?page=1&limit=10" -b cookies.txtResponse 200:
{
"id": "cm3k5j8l90000xyz",
"email": "[email protected]",
"fullName": "John Doe",
"role": "USER",
"isActive": true,
"createdAt": "2025-11-16T10:30:00.000Z",
"updatedAt": "2025-11-16T10:30:00.000Z"
}curl:
curl -X GET http://localhost:8080/api/v1/users/cm3k5j8l90000xyz -b cookies.txtRequest:
{
"fullName": "Updated Name",
"role": "ADMIN",
"isActive": false
}Response 200:
{
"id": "cm3k5j8l90000xyz",
"email": "[email protected]",
"fullName": "Updated Name",
"role": "ADMIN",
"isActive": false,
"createdAt": "2025-11-16T10:30:00.000Z",
"updatedAt": "2025-11-16T10:30:00.000Z"
}curl:
curl -X PATCH http://localhost:8080/api/v1/users/cm3k5j8l90000xyz \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{"role":"ADMIN"}'Soft deletes user (sets isActive: false) and invalidates all refresh tokens.
Response 200:
{
"message": "User deleted successfully"
}curl:
curl -X DELETE http://localhost:8080/api/v1/users/cm3k5j8l90000xyz -b cookies.txtPublic endpoints (no auth required).
| Method | Endpoint | Description | Use Case |
|---|---|---|---|
| GET | /health |
Full health check with DB | Load balancer checks |
| GET | /health/ready |
Readiness probe | Kubernetes readiness |
| GET | /health/live |
Liveness probe | Kubernetes liveness |
Response 200:
{
"status": "ok",
"info": {
"database": {
"status": "up"
}
},
"error": {},
"details": {
"database": {
"status": "up"
}
}
}curl:
curl http://localhost:8080/api/v1/healthResponse 200:
{
"status": "ok",
"info": {
"database": {
"status": "up"
},
"application": {
"status": "up",
"uptime": 3600.5,
"timestamp": "2025-11-16T10:30:00.000Z"
}
}
}curl:
curl http://localhost:8080/api/v1/health/readyResponse 200:
{
"status": "ok",
"uptime": 3600.5,
"timestamp": "2025-11-16T10:30:00.000Z",
"pid": 12345
}curl:
curl http://localhost:8080/api/v1/health/livesequenceDiagram
participant C as Client
participant A as AuthService
participant D as Database
C->>A: POST /auth/login
A->>D: Validate credentials
A->>A: Generate JWT tokens
A->>D: Store hashed refresh token
A->>C: Set httpOnly cookies (accessToken + refreshToken)
Note over C,A: 1 hour later...
C->>A: POST /auth/refresh (cookie)
A->>D: Find token in DB
A->>A: Generate NEW tokens
A->>D: Invalidate OLD token
A->>C: Set new httpOnly cookies
// 1. prisma/schema.prisma
enum UserRole { USER ADMIN MODERATOR }
// 2. Run migration
npm run prisma:migrate
// 3. Any controller
@Roles(UserRole.MODERATOR)
@Delete('posts/:id')
deletePost() { }{"level":30,"msg":"POST /auth/login - 200","userId":"cm3k...","durationMs":42}PII auto-redacted: passwords, tokens, cookies. Change via LOG_LEVEL env.
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
Yes | - | PostgreSQL connection string |
JWT_ACCESS_SECRET |
Yes | - | Access token secret (32+ chars) |
JWT_REFRESH_SECRET |
Yes | - | Refresh token secret (32+ chars) |
JWT_ACCESS_EXPIRY |
No | 60m |
Access token lifetime (format: 60m, 1h, 7d) |
JWT_REFRESH_EXPIRY |
No | 30d |
Refresh token lifetime |
NODE_ENV |
No | development |
development, production, or test |
PORT |
No | 8080 |
Server port |
CORS_ORIGIN |
No | http://localhost:3000 |
Allowed origins (comma-separated) |
LOG_LEVEL |
No | info |
fatal, error, warn, info, debug, trace |
Symptom: 401 on /auth/me after login
Fix: rm -rf node_modules && npm install && npm run prisma:generate
Symptom: "Can't reach database server"
Fix: pg_isready -h localhost -p 5432 then npm run prisma:migrate
Symptom: 401 on /auth/refresh
Fix: Clear cookies, login again: curl -X POST ... -c cookies.txt
Symptom: EADDRINUSE
Fix: lsof -ti:8080 | xargs kill -9 && npm run start:dev
Symptom: Browser shows Access-Control-Allow-Origin error
Fix: Add your frontend URL to CORS_ORIGIN in .env, restart server
Fork, branch (feature/name), PR. Run npm test before PR. Keep changes minimal.
MIT - see LICENSE file.
Built this? Please star the repo so others find it.
Need help? Open an issue (response < 24h)
Built by Sabin Shrestha • GitHub
