Chapter 49: Node.js API Authentication Guide
API authentication in Node.js (2025–2026 style).
We will build it step by step together — as if I’m sitting next to you right now, showing code, running the server, explaining every decision, why this pattern is used in real companies, what most beginners get wrong, security pitfalls, and current best practices.
We will implement JWT-based authentication with refresh tokens — the most common and production-ready pattern in modern Node.js APIs today.
0. What we are actually building (realistic goal)
A secure, production-grade authentication system that includes:
- User registration
- User login → returns access token (short-lived) + refresh token (long-lived)
- Refresh token endpoint → get new access token without re-login
- Protected routes → only authenticated users can access
- Logout (invalidate refresh token)
- Input validation (Zod)
- Secure cookie handling (httpOnly, secure, sameSite)
- Proper error responses
- Rate limiting on login attempts
- TypeScript + ESM
This is exactly the authentication layer you see in most serious Node.js APIs in 2025–2026.
1. Project setup (modern & realistic)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
mkdir auth-api cd auth-api npm init -y npm pkg set type=module # Core runtime dependencies npm install \ express \ cors \ helmet \ compression \ dotenv \ zod \ jsonwebtoken \ bcryptjs \ cookie-parser \ express-rate-limit # Development dependencies npm install -D \ typescript \ @types/node \ @types/express \ @types/cors \ @types/jsonwebtoken \ @types/bcryptjs \ @types/cookie-parser \ tsx \ nodemon \ eslint \ prettier \ eslint-config-standard-with-typescript \ @typescript-eslint/parser \ @typescript-eslint/eslint-plugin |
tsconfig.json (strict & modern)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitAny": true, "skipLibCheck": true, "outDir": "./dist", "rootDir": "./src", "sourceMap": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } |
package.json scripts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
"scripts": { "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "build": "tsc", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", "format": "prettier --write .", "typecheck": "tsc --noEmit" } |
2. Folder structure (what most real teams use)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
auth-api/ ├── src/ │ ├── config/ ← env + constants │ │ └── env.ts │ ├── controllers/ ← HTTP handlers │ │ └── auth.controller.ts │ ├── middleware/ ← auth, validation, error, rate-limit │ │ ├── auth.middleware.ts │ │ ├── error.middleware.ts │ │ ├── rate-limit.middleware.ts │ │ └── validate.middleware.ts │ ├── models/ ← in-memory or DB models │ │ └── user.model.ts │ ├── routes/ ← route definitions │ │ └── auth.routes.ts │ ├── schemas/ ← Zod schemas │ │ └── auth.schema.ts │ ├── services/ ← business logic │ │ └── auth.service.ts │ ├── types/ ← shared types │ │ └── auth.types.ts │ └── index.ts ← entry point ├── .env ├── .env.example ├── tsconfig.json └── package.json |
3. Environment + Zod validation (safety first)
src/config/env.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import { z } from 'zod' import 'dotenv/config' const envSchema = z.object({ PORT: z.coerce.number().default(5000), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), JWT_SECRET: z.string().min(32, 'JWT_SECRET is required and must be ≥ 32 chars'), JWT_ACCESS_EXPIRY: z.string().default('15m'), JWT_REFRESH_EXPIRY: z.string().default('7d'), REFRESH_TOKEN_COOKIE_NAME: z.string().default('refreshToken'), COOKIE_SECURE: z.coerce.boolean().default(false) }) const env = envSchema.parse(process.env) export default env |
Important security note
- JWT_SECRET should be at least 32 characters, random, never committed
- COOKIE_SECURE: true in production (HTTPS only)
4. In-memory user model (for learning – later replace with real DB)
src/models/user.model.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import { randomUUID } from 'node:crypto' import bcrypt from 'bcryptjs' interface User { id: string email: string passwordHash: string createdAt: Date } const users: User[] = [] export async function createUser(email: string, password: string): Promise<User> { const existing = users.find(u => u.email === email) if (existing) throw new Error('User already exists') const passwordHash = await bcrypt.hash(password, 12) const user: User = { id: randomUUID(), email, passwordHash, createdAt: new Date() } users.push(user) return user } export async function findUserByEmail(email: string): Promise<User | null> { return users.find(u => u.email === email) ?? null } export async function verifyPassword(password: string, hash: string): Promise<boolean> { return bcrypt.compare(password, hash) } |
5. JWT Helpers (real production style)
src/services/auth.service.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
import jwt from 'jsonwebtoken' import env from '../config/env.js' import { AppError } from '../middleware/error.middleware.js' interface TokenPayload { userId: string email: string role?: string } export function generateAccessToken(payload: TokenPayload): string { return jwt.sign(payload, env.JWT_SECRET, { expiresIn: env.JWT_ACCESS_EXPIRY }) } export function generateRefreshToken(payload: TokenPayload): string { return jwt.sign(payload, env.JWT_SECRET, { expiresIn: env.JWT_REFRESH_EXPIRY }) } export function verifyToken(token: string): TokenPayload { try { return jwt.verify(token, env.JWT_SECRET) as TokenPayload } catch (err) { throw new AppError(401, 'Invalid or expired token') } } |
6. Authentication middleware (protect routes)
src/middleware/auth.middleware.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import { Request, Response, NextFunction } from 'express' import { verifyToken } from '../services/auth.service.js' import { AppError } from './error.middleware.js' import env from '../config/env.js' import cookieParser from 'cookie-parser' export function authenticate(req: Request, res: Response, next: NextFunction) { // Try to get token from Authorization header (Bearer) or cookie let token = req.headers.authorization?.split(' ')[1] if (!token) { token = req.cookies[env.REFRESH_TOKEN_COOKIE_NAME] } if (!token) { throw new AppError(401, 'No authentication token provided') } try { const payload = verifyToken(token) req.user = payload next() } catch (err) { throw new AppError(401, 'Invalid or expired token') } } |
7. Register & Login routes
src/routes/auth.routes.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
import { Router } from 'express' import { z } from 'zod' import { createUser, findUserByEmail, verifyPassword } from '../models/user.model.js' import { generateAccessToken, generateRefreshToken } from '../services/auth.service.js' import env from '../config/env.js' import { AppError } from '../middleware/error.middleware.js' const router = Router() const registerSchema = z.object({ email: z.string().email(), password: z.string().min(8), name: z.string().min(2).optional() }) const loginSchema = z.object({ email: z.string().email(), password: z.string().min(8) }) router.post('/register', async (req, res, next) => { try { const input = registerSchema.parse(req.body) const user = await createUser(input.email, input.password) const accessToken = generateAccessToken({ userId: user.id, email: user.email }) const refreshToken = generateRefreshToken({ userId: user.id, email: user.email }) res.cookie(env.REFRESH_TOKEN_COOKIE_NAME, refreshToken, { httpOnly: true, secure: env.COOKIE_SECURE, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days }) res.status(201).json({ success: true, user: { id: user.id, email: user.email }, accessToken }) } catch (err) { next(err) } }) router.post('/login', async (req, res, next) => { try { const { email, password } = loginSchema.parse(req.body) const user = await findUserByEmail(email) if (!user) throw new AppError(401, 'Invalid credentials') const valid = await verifyPassword(password, user.passwordHash) if (!valid) throw new AppError(401, 'Invalid credentials') const accessToken = generateAccessToken({ userId: user.id, email: user.email }) const refreshToken = generateRefreshToken({ userId: user.id, email: user.email }) res.cookie(env.REFRESH_TOKEN_COOKIE_NAME, refreshToken, { httpOnly: true, secure: env.COOKIE_SECURE, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 }) res.json({ success: true, user: { id: user.id, email: user.email }, accessToken }) } catch (err) { next(err) } }) router.post('/refresh', (req, res, next) => { const refreshToken = req.cookies[env.REFRESH_TOKEN_COOKIE_NAME] if (!refreshToken) { throw new AppError(401, 'No refresh token') } try { const payload = verifyToken(refreshToken) const newAccessToken = generateAccessToken({ userId: payload.userId, email: payload.email }) res.json({ success: true, accessToken: newAccessToken }) } catch (err) { res.clearCookie(env.REFRESH_TOKEN_COOKIE_NAME) throw new AppError(401, 'Invalid refresh token') } }) router.post('/logout', (req, res) => { res.clearCookie(env.REFRESH_TOKEN_COOKIE_NAME) res.json({ success: true, message: 'Logged out' }) }) export default router |
Step 8 – Putting it all together
src/index.ts (final version)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import express from 'express' import cors from 'cors' import helmet from 'helmet' import compression from 'compression' import cookieParser from 'cookie-parser' import env from './config/env.js' import { errorHandler } from './middleware/error.middleware.js' import { apiLimiter } from './middleware/rate-limit.middleware.js' import authRoutes from './routes/auth.routes.js' const app = express() // Security & parsing app.use(helmet()) app.use(compression()) app.use(cors({ credentials: true, origin: true })) app.use(cookieParser()) app.use(express.json()) // Rate limiting app.use('/api/auth', apiLimiter) // Routes app.use('/api/auth', authRoutes) // Health check app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime() }) }) // Error handler – last app.use(errorHandler) app.listen(env.PORT, () => { console.log(`🚀 Auth API running → http://localhost:${env.PORT}`) }) |
Summary – What you now have (realistic production foundation)
- ESM + TypeScript
- Secure JWT + refresh token flow
- httpOnly cookies
- Zod validation
- Custom errors + global handler
- Rate limiting on auth endpoints
- Security headers & compression
- Environment validation
This is very close to what real companies use as their auth foundation in 2025–2026 (with database & refresh token storage added).
Which part would you like to extend next?
- Add real database (Prisma / Drizzle) + refresh token storage
- Implement email verification / password reset flow
- Add role-based access control (RBAC)
- Add unit & integration tests for auth
- Add Dockerfile + production hardening (helmet tweaks, rate-limit, etc.)
Just tell me what you want to build next — I’ll continue with complete, secure, production-ready code. 😊
