Most Node.js API tutorials stop at "it works." This guide focuses on what comes next: making sure it works securely. We'll build a production-ready security layer covering authentication, rate limiting, CORS, secure headers, input validation, and the most common attack vectors — all with concrete, copy-pasteable code.
1. JWT Authentication
JSON Web Tokens are the standard for stateless API authentication. A JWT contains claims (user ID, role, expiry) signed with a secret — the server verifies the signature on every request without hitting the database.
npm install jsonwebtoken bcryptjs
npm install -D @types/jsonwebtoken @types/bcryptjs
// lib/auth.ts
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!
export function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, 12)
}
export function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash)
}
export function signAccessToken(userId: number, role: string): string {
return jwt.sign({ userId, role }, ACCESS_SECRET, { expiresIn: '15m' })
}
export function signRefreshToken(userId: number): string {
return jwt.sign({ userId }, REFRESH_SECRET, { expiresIn: '7d' })
}
export function verifyAccessToken(token: string): { userId: number; role: string } {
return jwt.verify(token, ACCESS_SECRET) as { userId: number; role: string }
}
export function verifyRefreshToken(token: string): { userId: number } {
return jwt.verify(token, REFRESH_SECRET) as { userId: number }
}
Auth Routes
// routes/auth.ts
import { Router } from 'express'
import { prisma } from '../lib/prisma'
import { hashPassword, verifyPassword, signAccessToken, signRefreshToken, verifyRefreshToken } from '../lib/auth'
const router = Router()
router.post('/register', async (req, res) => {
const { email, password, name } = req.body
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) return res.status(409).json({ error: 'Email already registered' })
const user = await prisma.user.create({
data: { email, name, password: await hashPassword(password) },
})
const accessToken = signAccessToken(user.id, user.role)
const refreshToken = signRefreshToken(user.id)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
})
res.status(201).json({ accessToken, user: { id: user.id, email: user.email, name: user.name } })
})
router.post('/login', async (req, res) => {
const { email, password } = req.body
const user = await prisma.user.findUnique({ where: { email } })
if (!user || !(await verifyPassword(password, user.password))) {
// Same message for both cases — prevents email enumeration
return res.status(401).json({ error: 'Invalid credentials' })
}
const accessToken = signAccessToken(user.id, user.role)
const refreshToken = signRefreshToken(user.id)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
})
res.json({ accessToken })
})
router.post('/refresh', (req, res) => {
const token = req.cookies?.refreshToken
if (!token) return res.status(401).json({ error: 'No refresh token' })
try {
const { userId } = verifyRefreshToken(token)
// In production: also check token is in a whitelist in Redis/DB
const accessToken = signAccessToken(userId, 'USER')
res.json({ accessToken })
} catch {
res.status(401).json({ error: 'Invalid refresh token' })
}
})
router.post('/logout', (req, res) => {
res.clearCookie('refreshToken')
res.json({ success: true })
})
export default router
Auth Middleware
// middleware/authenticate.ts
import { Request, Response, NextFunction } from 'express'
import { verifyAccessToken } from '../lib/auth'
declare global {
namespace Express {
interface Request {
user?: { userId: number; role: string }
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' })
}
const token = header.slice(7)
try {
req.user = verifyAccessToken(token)
next()
} catch {
res.status(401).json({ error: 'Invalid or expired token' })
}
}
export function requireRole(role: string) {
return (req: Request, res: Response, next: NextFunction) => {
if (req.user?.role !== role) {
return res.status(403).json({ error: 'Forbidden' })
}
next()
}
}
2. Rate Limiting
npm install express-rate-limit rate-limit-redis ioredis
import rateLimit from 'express-rate-limit'
import { RedisStore } from 'rate-limit-redis'
import redis from '../lib/redis'
// General API limiter
export const apiLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
message: { error: 'Too many requests, please try again in a minute' },
})
// Strict limiter for auth endpoints
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({ sendCommand: (...args) => redis.call(...args) }),
skipSuccessfulRequests: true, // only count failed attempts
message: { error: 'Too many login attempts. Try again in 15 minutes.' },
})
// Apply in app.ts
app.use('/api', apiLimiter)
app.use('/api/auth/login', authLimiter)
app.use('/api/auth/register', authLimiter)
3. CORS Configuration
npm install cors
npm install -D @types/cors
import cors from 'cors'
const allowedOrigins = [
'https://yourapp.com',
'https://www.yourapp.com',
...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000', 'http://localhost:5173'] : []),
]
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, Postman in dev)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true)
} else {
callback(new Error(`CORS: Origin ${origin} not allowed`))
}
},
credentials: true, // allow cookies (needed for refresh tokens)
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400, // preflight cache 24 hours
}))
4. Security Headers with Helmet
npm install helmet
import helmet from 'helmet'
app.use(helmet())
// Helmet sets these headers automatically:
// Content-Security-Policy
// X-DNS-Prefetch-Control
// X-Frame-Options: SAMEORIGIN (prevents clickjacking)
// X-Content-Type-Options: nosniff (prevents MIME sniffing)
// Referrer-Policy: no-referrer
// Strict-Transport-Security (HSTS)
// X-XSS-Protection: 0 (modern browsers use CSP instead)
// Custom CSP if you serve HTML
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // tighten in production
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
},
}))
5. Input Validation with Zod
Never trust data from the client. Validate every request body, query param, and path param:
npm install zod
import { z } from 'zod'
import { Request, Response, NextFunction } from 'express'
// Reusable validator middleware factory
export function validate(schema: z.ZodTypeAny) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body)
if (!result.success) {
return res.status(422).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
})
}
req.body = result.data // replace with sanitized, parsed data
next()
}
}
// Define schemas
const CreatePostSchema = z.object({
title: z.string().min(1).max(200).trim(),
content: z.string().max(50_000).optional(),
tags: z.array(z.string().max(50)).max(10).optional(),
})
const RegisterSchema = z.object({
email: z.string().email().toLowerCase(),
password: z.string().min(8).max(72), // bcrypt max input is 72 bytes
name: z.string().min(1).max(100).trim(),
})
// Apply to routes
app.post('/posts', authenticate, validate(CreatePostSchema), async (req, res) => {
const post = await prisma.post.create({
data: { ...req.body, authorId: req.user!.userId },
})
res.status(201).json(post)
})
app.post('/auth/register', authLimiter, validate(RegisterSchema), registerHandler)
6. SQL Injection Prevention
Using Prisma or any parameterized query library protects you from SQL injection automatically. Never build queries with string concatenation:
// NEVER do this — SQL injection vulnerability
const users = await db.query(`SELECT * FROM users WHERE email = '${email}'`)
// SAFE — parameterized query (Prisma, pg, mysql2 all do this automatically)
const users = await prisma.user.findMany({ where: { email } })
// SAFE — raw query with parameters (tagged template literal in Prisma)
const users = await prisma.$queryRaw`SELECT * FROM "User" WHERE email = ${email}`
// SAFE — with pg or mysql2
const { rows } = await pool.query('SELECT * FROM users WHERE email = $1', [email])
7. Preventing Mass Assignment
Never pass req.body directly to a database operation — a malicious user can set fields you didn't intend:
// DANGEROUS — user could set role: 'ADMIN' in the request body
await prisma.user.update({ where: { id }, data: req.body })
// SAFE — whitelist only the fields you want to allow
const { name, bio } = req.body
await prisma.user.update({ where: { id }, data: { name, bio } })
// Or use Zod-validated schema (the validated result only contains whitelisted fields)
const { name, bio } = UpdateProfileSchema.parse(req.body)
await prisma.user.update({ where: { id }, data: { name, bio } })
8. Secure Cookies
import cookieParser from 'cookie-parser'
app.use(cookieParser())
// Refresh token cookie settings
res.cookie('refreshToken', token, {
httpOnly: true, // not accessible from JavaScript — prevents XSS theft
secure: true, // HTTPS only in production
sameSite: 'strict', // prevents CSRF (no cross-site requests carry the cookie)
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth', // only sent to auth routes
})
9. Hiding Sensitive Data from Responses
// Option 1: Explicit field selection with Prisma
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, email: true, name: true, role: true },
// password field NOT included
})
// Option 2: Delete before sending
const user = await prisma.user.findUnique({ where: { id } })
const { password, ...safeUser } = user
res.json(safeUser)
// Option 3: Zod output schema (transforms API response)
const UserResponseSchema = z.object({
id: z.number(),
email: z.string(),
name: z.string().nullable(),
role: z.string(),
})
app.get('/me', authenticate, async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.user!.userId } })
res.json(UserResponseSchema.parse(user)) // strips any field not in the schema
})
10. Security Checklist for Production
- Use HTTPS everywhere — never serve API traffic over HTTP in production
- Store secrets in environment variables, never in code
- Access tokens: 15-minute expiry. Refresh tokens: 7 days, stored in
httpOnlycookie - Keep dependencies updated — run
npm auditweekly, automate with Dependabot - Disable the
X-Powered-By: Expressheader:app.disable('x-powered-by') - Log auth events (login, logout, failed attempts) to a centralized log aggregator
- Test with OWASP ZAP or Burp Suite before deploying sensitive APIs
- Use a WAF (Cloudflare) in front of your API for DDoS and bot protection
Summary
A secure Node.js API combines multiple layers: JWT for stateless auth with short-lived access tokens and httpOnly refresh cookies, Helmet for secure headers, CORS with an explicit allowlist, rate limiting on auth endpoints, Zod validation on all inputs, and parameterized queries for database access. No single layer is sufficient — security in depth is the only approach that works.
CREESOL