Securing Node.js APIs: A Production-Ready Checklist for 2026
Security is non-negotiable for production APIs. This comprehensive guide covers authentication, authorization, input sanitization, rate limiting, HTTPS enforcement, and common attack prevention with practical Node.js code examples.

A secure Node.js API doesn't happen by accident. In 2026, the threat landscape includes automated bots, credential stuffing, injection attacks, and API abuse. This checklist covers 10 critical security layers you must implement before deploying to production. Each section includes ready-to-use code and configuration patterns from real-world applications.
1. Authentication: JWT Best Practices (Not Defaults)
Most JWT tutorials are insecure by default. Here's the production-grade approach:
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
// Use strong secrets: at least 256 bits, never hardcoded
const JWT_SECRET = process.env.JWT_SECRET; // Must be 32+ chars
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET; // Different secret!
function generateTokens(userId) {
// Short-lived access token (15 minutes)
const accessToken = jwt.sign(
{ sub: userId, type: ‘access’ },
JWT_SECRET,
{ expiresIn: ‘15m’, issuer: ‘your-api’, audience: ‘your-client’ }
);
// Long-lived refresh token (7 days) stored in HTTP-only cookie
const refreshToken = jwt.sign(
{ sub: userId, type: ‘refresh’, tokenId: crypto.randomUUID() },
JWT_REFRESH_SECRET,
{ expiresIn: ‘7d’ }
);
return { accessToken, refreshToken };
}
// Verify middleware with audience and issuer checks
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: ‘Missing token’ });
}
const token = authHeader.split(’ ')[1];
try {
const payload = jwt.verify(token, JWT_SECRET, {
issuer: ‘your-api’,
audience: ‘your-client’
});
req.user = payload;
next();
} catch (err) {
if (err.name === ‘TokenExpiredError’) {
return res.status(401).json({ error: ‘Token expired’ });
}
return res.status(403).json({ error: ‘Invalid token’ });
}
}
Critical: Never store JWTs in localStorage (XSS vulnerability). Use HTTP-only, Secure, SameSite=Strict cookies for refresh tokens.
2. Rate Limiting to Prevent Brute Force and DDoS
Implement per-IP and per-user rate limits using Redis-backed stores:
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redisClient = new Redis(process.env.REDIS_URL);
// Strict limit for auth endpoints
const authLimiter = rateLimit({
store: new RedisStore({ client: redisClient }),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts
message: { error: ‘Too many login attempts, try again later’ },
keyGenerator: (req) => req.ip // Or req.body.email for user-specific
});
// General API limit
const apiLimiter = rateLimit({
store: new RedisStore({ client: redisClient }),
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
standardHeaders: true, // Return RateLimit headers
legacyHeaders: false
});
app.use(‘/api/auth/login’, authLimiter);
app.use(‘/api/’, apiLimiter);
3. Input Validation and Sanitization
Never trust user input. Use a validation library like Zod or Joi:
import { z } from 'zod';
const userSchema = z.object({
email: z.string().email().max(255),
password: z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/),
name: z.string().min(1).max(100).regex(/^[a-zA-Z\s]+$/)
});
app.post(‘/api/users’, async (req, res) => {
const result = userSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.errors });
}
// result.data is now safe to use
});
Prevent NoSQL injection: Always use parameterized queries for SQL. For MongoDB, use mongoose schemas with type casting.
4. Security Headers with Helmet
Helmet.js sets 12 HTTP headers that mitigate common attacks:
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: [“‘self’”],
styleSrc: [“‘self’”, “‘unsafe-inline’”],
scriptSrc: [“‘self’”],
imgSrc: [“‘self’”, “data:”, “https://cdn.trusted.com”],
},
},
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
}
}));
// Also set these manually if not using Helmet
app.use((req, res, next) => {
res.setHeader(‘X-Content-Type-Options’, ‘nosniff’);
res.setHeader(‘X-Frame-Options’, ‘DENY’);
res.setHeader(‘Referrer-Policy’, ‘strict-origin-when-cross-origin’);
next();
});
5. HTTPS and TLS Enforcement
In production, never run HTTP without TLS. Enforce HTTPS redirects:
// At the load balancer level (Nginx, AWS ALB) is better, but code fallback:
app.use((req, res, next) => {
if (req.headers['x-forwarded-proto'] !== 'https' && process.env.NODE_ENV === 'production') {
return res.redirect(301, `https://${req.headers.host}${req.url}`);
}
next();
});
6. Preventing SQL/NoSQL Injection
Parameterized queries are non-negotiable:
// BAD: string concatenation
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`;
// GOOD: parameterized (PostgreSQL example)
const result = await db.query(
‘SELECT * FROM users WHERE email = $1’,
[req.body.email]
);
7. Cross-Site Request Forgery (CSRF) Protection
For state-changing operations (POST/PUT/DELETE), implement CSRF tokens:
import csrf from 'csrf';
const tokens = new csrf();
// Generate token and send in cookie/header
app.get(‘/api/csrf-token’, (req, res) => {
const secret = tokens.secretSync();
req.session.csrfSecret = secret;
const token = tokens.create(secret);
res.json({ csrfToken: token });
});
// Verify on mutations
app.post(‘/api/transfer’, (req, res) => {
const { csrfToken } = req.body;
const secret = req.session.csrfSecret;
if (!tokens.verify(secret, csrfToken)) {
return res.status(403).json({ error: ‘Invalid CSRF token’ });
}
// Process transfer
});
8. Dependency Vulnerability Scanning
Run npm audit in CI and consider Snyk or Dependabot:
# package.json
{
"scripts": {
"security:check": "npm audit --audit-level=high"
}
}
GitHub Actions
- run: npm audit --production --audit-level=moderate
9. Error Handling: Never Leak Implementation Details
Stack traces and database errors reveal attack surface:
// Global error handler
app.use((err, req, res, next) => {
// Log full error internally
console.error(err);
// Send generic message to client
res.status(500).json({
error: process.env.NODE_ENV === ‘production’
? ‘Internal server error’
: err.message
});
});
10. Security Monitoring and Logging
Log all authentication attempts, rate limit violations, and errors with structured logging (Winston/Pino):
import pino from 'pino';
const logger = pino({ level: 'info' });
app.use((req, res, next) => {
const start = Date.now();
res.on(‘finish’, () => {
logger.info({
method: req.method,
url: req.url,
status: res.statusCode,
duration: Date.now() - start,
ip: req.ip,
userAgent: req.headers[‘user-agent’]
});
});
next();
});
Conclusion
Security is a process, not a one-time setup. Implement these 10 layers, run regular penetration testing, and subscribe to CVE notifications for your dependencies. Start with authentication, rate limiting, and Helmet — these stop 80% of automated attacks. Then incrementally add the remaining layers. Your users (and your future self) will thank you.
Comments
Join the conversation — sign in to leave a comment.