Node.js
June 10, 20267 min read...
Node.jsJune 10, 20267 min read

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.

Securing Node.js APIs: A Production-Ready Checklist for 2026

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.