JWT Authentication Module

Enterprise-grade JWT authentication built with Express & TypeScript. Zero-database, fully stateless, production-ready.

9API Endpoints
102Tests Passing
10Error Codes
0Dependencies on DB
TypeScript 5.5 Express 4 Node 20 JWT HS256

Quick Start

1. Clone & Install

bash
git clone https://github.com/hoangsonww/jwt-module.git
cd jwt-module
npm install

2. Configure Environment

.env
PORT=5001
JWT_ACCESS_SECRET=your-access-secret-min-32-chars
JWT_REFRESH_SECRET=your-refresh-secret-min-32-chars
CORS_ORIGIN=http://localhost:3000
NODE_ENV=development

3. Run

bash
# Development
PORT=5001 npx ts-node src/server.ts

# Production (build first)
npm run build
NODE_ENV=production node dist/server.js

# Docker
docker compose up --build

4. Test It

bash
# Register
curl -X POST http://localhost:5001/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"Secret123"}'

# Login
curl -X POST http://localhost:5001/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@example.com","password":"Secret123"}'

# Access protected route
curl http://localhost:5001/auth/me \
  -H "Authorization: Bearer <access_token>"

🏗 Architecture Overview

The module follows a clean layered architecture with strict separation between the HTTP API layer and the authentication business logic.

System Architecture
graph TB subgraph Client["Client Layer"] Browser["Browser / Mobile App"] CLI["CLI / curl"] end subgraph API["API Layer (src/api/)"] Express["Express App
helmet, cors, json"] RL["Rate Limiter
Per-IP sliding window"] Val["Zod Validation
Schema-based input check"] MW["Auth Middleware
Bearer token verification"] Router["Auth Router
9 endpoints"] end subgraph Auth["Auth Layer (src/auth/)"] AS["Auth Service
register, login, refresh
logout, password, email"] TM["Token Manager
JWT sign/verify
Revocation blacklist"] PW["Password Utils
bcrypt hash/verify
Strength validation"] ERR["Error System
AuthError + codes"] end subgraph Storage["In-Memory Storage"] Users["Users Map<id, User>"] Tokens["Revoked Tokens Map"] Sessions["User Tokens Map"] Attempts["Login Attempts Map"] end Browser --> Express CLI --> Express Express --> RL RL --> Val Val --> MW MW --> Router Router --> AS AS --> TM AS --> PW AS --> ERR AS --> Users TM --> Tokens AS --> Sessions AS --> Attempts style Client fill:#161b22,stroke:#30363d,color:#e6edf3 style API fill:#161b22,stroke:#1f6feb,color:#e6edf3 style Auth fill:#161b22,stroke:#bc8cff,color:#e6edf3 style Storage fill:#161b22,stroke:#3fb950,color:#e6edf3

Key Design Decisions

  • No database: All state is in-memory Maps. This keeps the module self-contained and eliminates infrastructure dependencies.
  • Interface-driven: The API layer consumes auth via the AuthService interface, making the auth implementation swappable.
  • Layer separation: Business logic in src/auth/ never imports Express. HTTP concerns stay in src/api/.
  • Error-first: Typed AuthError codes propagate from auth layer to HTTP via ERROR_STATUS_MAP.

🔀 Authentication Flows

Complete Auth Lifecycle

Authentication Sequence — Register, Login, Use, Refresh, Logout
sequenceDiagram participant C as Client participant API as API Layer participant AS as Auth Service participant TM as Token Manager participant PW as Password Utils participant DB as In-Memory Store Note over C,DB: Registration Flow C->>API: POST /auth/register {email, password} API->>API: Zod validation + rate limit API->>AS: register(input) AS->>PW: validatePasswordStrength(password) PW-->>AS: pass / throw WEAK_PASSWORD AS->>PW: hashPassword(password) PW-->>AS: bcrypt hash (12 rounds) AS->>DB: store User{id, email, hash, createdAt} AS->>TM: generateTokens({userId, email}) TM-->>AS: {accessToken, refreshToken} AS-->>API: AuthTokens API-->>C: 201 {tokens: {accessToken, refreshToken}} Note over C,DB: Login Flow C->>API: POST /auth/login {email, password} API->>API: Zod validation + rate limit API->>AS: login(input) AS->>DB: findByEmail(email) AS->>AS: check lockout status AS->>PW: verifyPassword(input, hash) PW-->>AS: true/false AS->>TM: generateTokens({userId, email}) TM-->>AS: {accessToken, refreshToken} AS-->>API: AuthTokens API-->>C: 200 {tokens} Note over C,DB: Authenticated Request C->>API: GET /auth/me [Authorization: Bearer token] API->>TM: verifyAccessToken(token) TM-->>API: {userId, email} API->>AS: getUserById(userId) AS->>DB: users.get(id) AS-->>API: User API-->>C: 200 {id, email, createdAt} Note over C,DB: Token Refresh C->>API: POST /auth/refresh {refreshToken} API->>AS: refreshTokens(refreshToken) AS->>TM: verifyRefreshToken(token) TM-->>AS: payload AS->>TM: revokeRefreshToken(oldToken) AS->>TM: generateTokens(payload) TM-->>AS: new {accessToken, refreshToken} AS-->>API: AuthTokens API-->>C: 200 {tokens} Note over C,DB: Logout C->>API: POST /auth/logout {refreshToken} API->>AS: logout(refreshToken) AS->>TM: revokeRefreshToken(token) AS-->>API: void API-->>C: 200 Logged out successfully

Account Lockout Flow

Login Attempt Tracking & Account Lockout
flowchart TD A["POST /auth/login"] --> B{"Account locked?"} B -->|"Yes (within 15 min)"| C["423 ACCOUNT_LOCKED"] B -->|No| D["Find user by email"] D --> E{"User exists?"} E -->|No| F["401 INVALID_CREDENTIALS"] E -->|Yes| G["Verify password with bcrypt"] G --> H{"Password valid?"} H -->|No| I["Increment attempt counter"] I --> J{"attempts >= 5?"} J -->|Yes| K["Lock account for 15 minutes"] J -->|No| F K --> F H -->|Yes| L["Clear attempt counter"] L --> M["Generate token pair"] M --> N["200 + AuthTokens"] style C fill:#f85149,stroke:#f85149,color:#fff style F fill:#f85149,stroke:#f85149,color:#fff style N fill:#238636,stroke:#238636,color:#fff style K fill:#d29922,stroke:#d29922,color:#fff

🔑 Token Management

Token Lifecycle States

JWT Token State Machine
stateDiagram-v2 [*] --> Generated: generateTokens() Generated --> Active: Token issued to client state Active { AccessToken: Access Token (15 min) RefreshToken: Refresh Token (7 days) } Active --> Expired: TTL exceeded Active --> Revoked: logout / refresh rotation Revoked --> Pruned: pruneExpiredTokens() Expired --> [*]: Discarded Pruned --> [*]: Removed from blacklist note right of Active Access: used in Authorization header Refresh: used to get new token pair end note note right of Revoked Stored in revokedTokens Map TTL = 7 days end note

Token Rotation

Refresh Token Rotation Sequence
sequenceDiagram participant C as Client participant S as Server participant BL as Revocation Blacklist Note over C: Access token expired C->>S: POST /auth/refresh {refreshToken: RT1} S->>BL: Is RT1 revoked? BL-->>S: No S->>S: Verify RT1 signature + expiry S->>BL: Revoke RT1 (add to blacklist) S->>S: Generate new AT2 + RT2 S-->>C: {accessToken: AT2, refreshToken: RT2} Note over C: Client must use RT2 next time Note over C: RT1 is now permanently invalid C->>S: POST /auth/refresh {refreshToken: RT1} S->>BL: Is RT1 revoked? BL-->>S: Yes - REVOKED S-->>C: 401 INVALID_TOKEN

Token Configuration

Token Type Algorithm Expiry Payload Storage
Access Token HS256 15 minutes userId, email Client only
Refresh Token HS256 7 days userId, email Client + server tracking

Error Handling

Error Architecture

Error Propagation — AuthError to HTTP Response
flowchart LR subgraph AuthLayer["Auth Layer"] AE["throw new AuthError(code, message)"] end subgraph Router["Auth Router"] Catch["catch (err)"] Check{"err instanceof AuthError?"} Map["ERROR_STATUS_MAP[err.code]"] Generic["500 INTERNAL_ERROR"] end subgraph Response["HTTP Response"] R1["{ error: { code, message } }"] end AE --> Catch Catch --> Check Check -->|Yes| Map Check -->|No| Generic Map --> R1 Generic --> R1 style AuthLayer fill:#161b22,stroke:#bc8cff,color:#e6edf3 style Router fill:#161b22,stroke:#1f6feb,color:#e6edf3 style Response fill:#161b22,stroke:#3fb950,color:#e6edf3

Error Code Reference

Code HTTP Status Description Trigger
DUPLICATE_EMAIL 409 Email already registered register, updateEmail
INVALID_CREDENTIALS 401 Wrong email or password login, changePassword, updateEmail, deleteAccount
INVALID_TOKEN 401 Token is malformed or revoked refresh, any authenticated route
TOKEN_EXPIRED 401 Token TTL exceeded refresh, any authenticated route
MISSING_SECRET 500 JWT secret env var not set Any token operation
INVALID_EMAIL 400 Email format validation failed register, updateEmail
WEAK_PASSWORD 400 Password doesn't meet policy register, changePassword
USER_NOT_FOUND 404 No user with given ID getUserById, changePassword, etc.
MISSING_TOKEN 401 No Authorization header Any authenticated route
ACCOUNT_LOCKED 423 Too many failed login attempts login (after 5 failures)

Response Shape

All error responses follow a consistent structure:

json
{
  "error": {
    "code": "INVALID_CREDENTIALS",
    "message": "Invalid email or password"
  }
}

📚 API Reference

All endpoints accept and return JSON. The base URL is http://localhost:5001.

POST /auth/register Public Rate Limited

Register a new user account. Returns access and refresh tokens.

Request Body
json
{
  "email": "user@example.com",
  "password": "Secret123"
}
Success Response — 201
json
{
  "tokens": {
    "accessToken": "eyJhbGciOiJIUzI1NiIs...",
    "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
  }
}
Error Responses
Status Code When
400 WEAK_PASSWORD Password < 8 chars or missing letter/digit
400 INVALID_EMAIL Email format invalid
409 DUPLICATE_EMAIL Email already registered
429 Rate limited Too many requests from IP
POST /auth/login Public Rate Limited

Authenticate with email and password. Returns a token pair. Account locks after 5 failed attempts for 15 minutes.

Request Body
json
{
  "email": "user@example.com",
  "password": "Secret123"
}
Success Response — 200
json
{
  "tokens": {
    "accessToken": "eyJhbGciOiJIUzI1NiIs...",
    "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
  }
}
Error Responses
Status Code When
401 INVALID_CREDENTIALS Wrong email or password
423 ACCOUNT_LOCKED 5+ failed attempts in window
429 Rate limited Too many requests from IP
POST /auth/refresh Public Rate Limited

Exchange a valid refresh token for a new token pair. The old refresh token is revoked (rotation).

Request Body
json
{ "refreshToken": "eyJhbGciOiJIUzI1NiIs..." }
Success Response — 200
json
{
  "tokens": {
    "accessToken": "new-access-token...",
    "refreshToken": "new-refresh-token..."
  }
}
Error Responses
Status Code When
401 INVALID_TOKEN Token revoked or malformed
401 TOKEN_EXPIRED Refresh token TTL exceeded
GET /auth/me Auth Required

Get the current authenticated user's profile.

Headers
http
Authorization: Bearer <access_token>
Success Response — 200
json
{
  "id": "clx9abc123def456",
  "email": "user@example.com",
  "createdAt": "2024-06-15T10:30:00.000Z"
}
PATCH /auth/me Auth Required

Update the authenticated user's email. Requires password confirmation.

Request Body
json
{
  "newEmail": "newemail@example.com",
  "password": "Secret123"
}
Success Response — 200
json
{ "message": "Email updated successfully" }
DELETE /auth/me Auth Required

Permanently delete the authenticated user's account. Revokes all sessions. Requires password confirmation.

Request Body
json
{ "password": "Secret123" }
Success Response — 200
json
{ "message": "Account deleted successfully" }
POST /auth/logout Public

Revoke a specific refresh token, ending that session.

Request Body
json
{ "refreshToken": "eyJhbGciOiJIUzI1NiIs..." }
Success Response — 200
json
{ "message": "Logged out successfully" }
POST /auth/logout-all Auth Required

Revoke all refresh tokens for the authenticated user, ending all sessions everywhere.

Success Response — 200
json
{ "message": "All sessions revoked successfully" }
POST /auth/change-password Auth Required Rate Limited

Change the authenticated user's password. All existing sessions are revoked after change.

Request Body
json
{
  "currentPassword": "OldSecret123",
  "newPassword": "NewSecret456"
}
Success Response — 200
json
{ "message": "Password changed successfully" }
GET /health Public

Health check endpoint for load balancers and orchestrators.

Success Response — 200
json
{ "status": "ok" }

Rate Limiting

The API uses a per-IP sliding window rate limiter to prevent brute-force and abuse.

Request Processing Pipeline
flowchart LR REQ["Incoming Request"] --> HELMET["Helmet
Security Headers"] HELMET --> CORS["CORS
Origin Check"] CORS --> LOG["Request Logger"] LOG --> JSON["JSON Parser
10kb limit"] JSON --> ROUTE{"Route Match?"} ROUTE -->|"Rate limited route"| RL["Rate Limiter
Per-IP window"] RL --> RLCHECK{"Under limit?"} RLCHECK -->|No| R429["429 Too Many Requests"] RLCHECK -->|Yes| ZOD["Zod Validation"] ROUTE -->|"Auth required"| MW["Auth Middleware
Verify Bearer token"] MW --> MWCHECK{"Valid token?"} MWCHECK -->|No| R401["401 Unauthorized"] MWCHECK -->|Yes| ZOD ROUTE -->|"Health check"| HEALTH["200 OK"] ZOD --> ZODCHECK{"Valid body?"} ZODCHECK -->|No| R400["400 Validation Error"] ZODCHECK -->|Yes| HANDLER["Route Handler"] HANDLER --> RES["JSON Response"] style R429 fill:#d29922,stroke:#d29922,color:#fff style R401 fill:#f85149,stroke:#f85149,color:#fff style R400 fill:#f85149,stroke:#f85149,color:#fff style HEALTH fill:#238636,stroke:#238636,color:#fff style RES fill:#238636,stroke:#238636,color:#fff

Rate Limited Endpoints

Endpoint Protected Notes
POST /auth/register Yes Prevents mass account creation
POST /auth/login Yes Works with account lockout
POST /auth/refresh Yes Prevents token farming
POST /auth/change-password Yes Prevents brute-force on current password
All other endpoints No Protected by auth middleware instead

🛡 Security Features

Security Defense Layers
graph TB subgraph L1["Layer 1: Transport"] HELMET["Helmet - Security Headers
X-Frame-Options, CSP, HSTS"] CORS["CORS - Origin Whitelist"] TLS["TLS Termination (via LB)"] end subgraph L2["Layer 2: Input"] RL["Rate Limiter - Per-IP Window"] JSON["JSON Body Limit - 10kb"] ZOD["Zod Schema Validation"] end subgraph L3["Layer 3: Authentication"] JWT["JWT Verification - HS256"] ROT["Token Rotation on Refresh"] REV["Token Revocation Blacklist"] LOCK["Account Lockout - 5 attempts / 15min"] end subgraph L4["Layer 4: Data"] BCRYPT["bcrypt - 12 salt rounds"] PWPOL["Password Policy - 8+ chars, letter+digit"] NOPII["No PII in tokens beyond email"] end subgraph L5["Layer 5: Runtime"] NONROOT["Non-root Docker user"] READONLY["Read-only container filesystem"] RESLIM["Resource limits (CPU/Memory)"] end L1 --> L2 L2 --> L3 L3 --> L4 L4 -.-> L5 style L1 fill:#161b22,stroke:#58a6ff,color:#e6edf3 style L2 fill:#161b22,stroke:#d29922,color:#e6edf3 style L3 fill:#161b22,stroke:#bc8cff,color:#e6edf3 style L4 fill:#161b22,stroke:#3fb950,color:#e6edf3 style L5 fill:#161b22,stroke:#f85149,color:#e6edf3
Production Note

This module uses in-memory storage. All user data is lost on restart. For production with persistence, implement the AuthService interface with a real database backend.

🔐 Password Security

Password Validation & Hashing Flow
flowchart TD INPUT["Password Input"] --> LEN{"Length >= 8?"} LEN -->|No| WEAK["throw WEAK_PASSWORD"] LEN -->|Yes| LETTER{"Has letter [a-zA-Z]?"} LETTER -->|No| WEAK LETTER -->|Yes| DIGIT{"Has digit [0-9]?"} DIGIT -->|No| WEAK DIGIT -->|Yes| HASH["bcrypt.hash(password, 12)"] HASH --> STORE["Store passwordHash in User record"] subgraph Verification["Login Verification"] INPUT2["Password Attempt"] --> VERIFY["bcrypt.compare(attempt, hash)"] VERIFY --> MATCH{"Match?"} MATCH -->|Yes| SUCCESS["Authentication Success"] MATCH -->|No| FAIL["Increment attempt counter"] end style WEAK fill:#f85149,stroke:#f85149,color:#fff style SUCCESS fill:#238636,stroke:#238636,color:#fff style FAIL fill:#d29922,stroke:#d29922,color:#fff

Password Policy

  • Minimum 8 characters
  • At least one letter (a-z or A-Z)
  • At least one digit (0-9)
  • Hashed with bcrypt using 12 salt rounds (~250ms per hash)
  • Plaintext password never stored or logged

🚀 Deployment

Cloud Deployment Architecture
graph TB subgraph Internet["Users / Clients"] U["Users / Clients"] end subgraph Cloud["Cloud Provider (AWS / Azure)"] LB["Load Balancer
ALB / App Gateway
TLS termination, WAF"] subgraph K8s["Kubernetes Cluster (ECS / AKS)"] Ingress["Ingress Controller
nginx / AGIC"] subgraph Pods["jwt-module Pods"] P1["Pod 1
:5001"] P2["Pod 2
:5001"] P3["Pod 3
:5001"] end HPA["HPA
Auto-scale 2-20"] SVC["ClusterIP Service"] end Registry["Container Registry
ECR / ACR"] Secrets["Secrets Manager
SSM / Key Vault"] Logs["Log Aggregation
CloudWatch / Log Analytics"] Alerts["Monitoring & Alerts
CloudWatch / Azure Monitor"] end U --> LB LB --> Ingress Ingress --> SVC SVC --> P1 SVC --> P2 SVC --> P3 HPA -.-> Pods Registry -.->|pull image| Pods Secrets -.->|inject env| Pods Pods -.->|logs| Logs Logs -.->|trigger| Alerts style Internet fill:#161b22,stroke:#58a6ff,color:#e6edf3 style Cloud fill:#0d1117,stroke:#30363d,color:#e6edf3 style K8s fill:#161b22,stroke:#bc8cff,color:#e6edf3 style Pods fill:#21262d,stroke:#3fb950,color:#e6edf3

Docker

bash
# Build and run with Docker Compose
export JWT_ACCESS_SECRET="your-secret-here"
export JWT_REFRESH_SECRET="your-secret-here"
docker compose up --build -d

# Check health
curl http://localhost:5001/health

Kubernetes (Kustomize)

bash
# Dev
kubectl apply -k k8s/overlays/dev

# Staging
kubectl apply -k k8s/overlays/staging

# Production
kubectl apply -k k8s/overlays/prod

Kubernetes (Helm)

bash
# Install
helm install jwt-module k8s/helm -f k8s/helm/values-prod.yaml

# Upgrade
helm upgrade jwt-module k8s/helm -f k8s/helm/values-prod.yaml

AWS (Terraform + ECS Fargate)

bash
cd aws/terraform
terraform init
terraform plan -var-file=environments/prod.tfvars
terraform apply -var-file=environments/prod.tfvars

Azure (Terraform + AKS)

bash
cd azure/terraform
terraform init
terraform plan -var-file=environments/prod.tfvars
terraform apply -var-file=environments/prod.tfvars

Configuration Reference

Variable Required Default Description
PORT No 3000 Server listen port
JWT_ACCESS_SECRET Yes* Dev fallback Secret for signing access tokens (HS256)
JWT_REFRESH_SECRET Yes* Dev fallback Secret for signing refresh tokens (HS256)
CORS_ORIGIN No * Allowed CORS origin
NODE_ENV No development Runtime environment
Security Warning

In production, always set JWT_ACCESS_SECRET and JWT_REFRESH_SECRET to strong, unique random strings (minimum 32 characters). The development fallback secrets in server.ts should never be used in production.

🧪 Testing

bash
# Run all tests
npm test

# Run with coverage report
npm run test:coverage

# Run specific test file
npx jest --config jest.config.ts src/tests/auth-service.test.ts

Test Suite Overview

File Coverage Description
auth-service.test.ts Auth Service Register, login, refresh, logout, password change, email update, delete, lockout
token.test.ts Token Utils Generate, verify, revoke, prune expired tokens
password.test.ts Password Utils Hash, verify, strength validation edge cases
middleware.test.ts Middleware Auth middleware, missing/invalid/expired tokens
api.test.ts Integration Full HTTP endpoint tests with supertest
Test Isolation

Every test file calls clearRevokedTokens(), clearLoginAttempts(), clearUserTokens(), and clearRateLimitWindows() in afterEach to prevent cross-test contamination.

📈 Module Dependency Graph

Project Module Dependencies
graph LR server["server.ts
(entry point)"] app["api/app.ts"] router["api/auth-router.ts"] mw["api/middleware.ts"] rl["api/rate-limiter.ts"] val["api/validation.ts"] as["auth/auth-service.ts"] token["auth/token.ts"] pw["auth/password.ts"] err["auth/errors.ts"] types["auth/types.ts"] server --> app server --> as app --> router app --> mw app --> types router --> err router --> mw router --> rl router --> val mw --> token as --> err as --> pw as --> token as --> types token --> err token --> types pw --> err style server fill:#1f6feb,stroke:#58a6ff,color:#fff style app fill:#21262d,stroke:#58a6ff,color:#e6edf3 style router fill:#21262d,stroke:#58a6ff,color:#e6edf3 style as fill:#21262d,stroke:#bc8cff,color:#e6edf3 style token fill:#21262d,stroke:#bc8cff,color:#e6edf3 style pw fill:#21262d,stroke:#bc8cff,color:#e6edf3 style err fill:#21262d,stroke:#f85149,color:#e6edf3 style types fill:#21262d,stroke:#3fb950,color:#e6edf3

🤝 Contributing

Development Setup

bash
git clone https://github.com/hoangsonww/jwt-module.git
cd jwt-module
npm install
npm test                   # Verify everything passes
PORT=5001 npx ts-node src/server.ts   # Start dev server

Project Conventions

  • All auth business logic goes in src/auth/ — never in the router
  • New error codes: add to AuthErrorCode union in errors.ts AND to ERROR_STATUS_MAP in auth-router.ts
  • New input schemas go in src/api/validation.ts using Zod
  • Tests must clean up state in afterEach (see Testing section)
  • No database or external service dependencies without an interface abstraction
  • TypeScript strict mode — no any, no @ts-ignore

Adding a New Endpoint

  1. Add the method to the AuthService interface in src/api/app.ts
  2. Implement the logic in src/auth/auth-service.ts
  3. Add Zod validation schema in src/api/validation.ts
  4. Add the route in src/api/auth-router.ts
  5. Add any new error codes to both errors.ts and ERROR_STATUS_MAP
  6. Write tests covering happy path, edge cases, and error cases