How to Secure a Node.js API: JWT, Rate Limiting, CORS & More (2026)

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 httpOnly cookie
  • Keep dependencies updated — run npm audit weekly, automate with Dependabot
  • Disable the X-Powered-By: Express header: 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.

Frequently Asked Questions

Should I store JWTs in localStorage or cookies?

Store refresh tokens in httpOnly cookies (not accessible to JavaScript) and keep access tokens in memory (a JavaScript variable). Never store tokens in localStorage — it's accessible to any JavaScript on the page, so an XSS vulnerability immediately exposes every user's token. The httpOnly + SameSite=Strict cookie combination prevents both XSS theft and CSRF attacks. Access tokens in memory are lost on page refresh, which is why the refresh-token pattern exists: silently get a new access token from the cookie on page load.

What is the difference between authentication and authorization?

Authentication answers "who are you?" — verifying identity via credentials (email/password, OAuth, magic link). Authorization answers "what are you allowed to do?" — checking whether the authenticated user has permission for a specific action. JWT handles authentication (the token identifies the user). Role checks (requireRole('ADMIN')) and resource ownership checks (post.authorId === req.user.userId) handle authorization. Both must be implemented; authentication without authorization means any logged-in user can do anything.

How do I invalidate a JWT before it expires?

JWTs are stateless — you can't "revoke" them from the server side without maintaining state. The standard approaches: (1) Keep access token TTL very short (15 minutes) so the damage window is small. (2) Maintain a token blacklist in Redis — on logout, add the token's jti (JWT ID claim) to a Redis set with the same TTL as the token; check the blacklist on every request. (3) Use a version number in your user record — include the version in the JWT; increment it on logout; verify the version matches on each request. Option 3 is the most robust for "logout everywhere" functionality.

What is CSRF and does it affect REST APIs?

CSRF (Cross-Site Request Forgery) tricks a user's browser into making an unintended request to your server using their cookies. For REST APIs that use JWT in the Authorization header (not cookies), CSRF is not a concern — cross-origin requests can't add custom headers. If you use cookies for tokens, CSRF is a risk. Setting SameSite=Strict on cookies prevents them from being sent cross-site, which is the simplest CSRF defense. Alternatively, use the Double Submit Cookie pattern or CSRF tokens for cookie-based auth.