This repository demonstrates how to build a production-ready authentication system using:
- NestJS
- PostgreSQL
- Docker Compose
- Passport.js (Local + JWT strategies)
- WebAuthn (Passkeys) as second-factor authentication
The project is built as a two-step authentication model:
- Users sign in with username and password
- Users verify with passkeys (WebAuthn) to upgrade their JWT and access sensitive routes
This mirrors how passkeys are realistically introduced in real-world systems — **without breaking existing login flows **.
This repository accompanies the following articles:
Building a production-ready authentication system with NestJS, PostgreSQL, JWT, and Passport.js
Covers:
- Project setup with NestJS and Docker Compose
- PostgreSQL integration with TypeORM
- User entity design
- Password hashing
- Passport Local Strategy
- JWT authentication and route guards
Integrating passkeys (WebAuthn) into an existing NestJS authentication system
Covers:
- Passkey architecture in production systems
- WebAuthn data modeling
- Passkey registration
- Passkey authentication
- JWT upgrade (step-up authentication)
- Cache-based challenge handling
[ Browser ]
│
├─ Username + Password ──▶ /auth/login
│ └─ JWT (authLevel=password)
│
├─ Passkey Register ─────▶ /passkeys/register/*
│
└─ Passkey Verify ───────▶ /passkeys/authenticate/*
└─ JWT (authLevel=full)
JwtAuthGuard→ requires loginFullAuthGuard→ requires passkey verification
No sessions are used. The system is fully stateless and horizontally scalable.
Backend:
- NestJS
- TypeORM
- PostgreSQL
- Passport.js
- JWT
- @simplewebauthn/server
- cache-manager (Redis-ready)
Frontend (demo only):
- Plain HTML
- Vanilla JavaScript
- Native WebAuthn APIs
Infrastructure:
- Docker Compose
- PostgreSQL container
git clone https://github.com/codetheworld-io/nestjs-auth-passkey.git
cd nestjs-auth-passkeynpm ci
Create .env:
DB_HOST=db
DB_PORT=5432
DB_USER=auth_user
DB_PASSWORD=auth_password
DB_NAME=auth_db
JWT_SECRET=thel@stFlyD0g
RP_NAME="NestJS Passkey Demo"
RP_ID=localhost
RP_ORIGIN=http://localhost:3000
In production, RP_ID must match your domain exactly and HTTPS is required.
docker compose up -d
PostgreSQL will be available inside the Docker network as db.
docker compose exec api npm run migration:generate
docker compose exec api npm run migration:run
API will be available at: http://localhost:3000
A very simple frontend is included for demonstration: public/index.html
It supports:
- Signup
- Signin
- Passkey registration
- Passkey authentication
- Calling protected routes
To use it: http://localhost:3000
This frontend is intentionally framework-free to clearly show how WebAuthn works with raw browser APIs.
| Method | Endpoint | Description |
|---|---|---|
| POST | /auth/signup | Create user account |
| POST | /auth/login | Password login → JWT |
| Method | Endpoint | Description |
|---|---|---|
| GET | /passkeys/register/options | Generate registration challenge |
| POST | /passkeys/register/verify | Verify & store credential |
| GET | /passkeys/authenticate/options | Generate auth challenge |
| POST | /passkeys/authenticate/verify | Verify & upgrade JWT |
| Guard | Access Level |
|---|---|
| JwtAuthGuard | Logged-in users |
| FullAuthGuard | Passkey verified |
This project demonstrates:
- Password hashing with bcrypt
- Challenge-based WebAuthn verification
- Signature counter enforcement
- Origin and RP ID validation
- Stateless JWT-based authorization
It does not include:
- Refresh token rotation
- Account recovery flows
- Device management UI
- Brute-force protection
These are intentionally excluded to keep the example focused.
Recommended next steps:
- Add Redis for distributed cache
- Implement refresh token rotation
- Add passkey device management APIs
- Enforce passkey on sensitive routes only
- Support multiple origins behind proxies
This codebase is structured to support all of the above without architectural changes.
Passkeys require:
- HTTPS (except for localhost)
- RP ID matching the domain
- Stable origins (no IP addresses in prod)
If deploying behind Nginx or cloud load balancers, ensure:
X-Forwarded-Protoheaders are set- TLS terminates before NestJS