How to Secure a Node.js API (Step-by-Step)
Most Node.js APIs get built fast. They get secured… later. This guide makes "later" happen right now — with practical, production-ready patterns for every layer of your API.
Loading articles...
Most Node.js APIs get built fast. They get secured… later. This guide makes "later" happen right now — with practical, production-ready patterns for every layer of your API.

This tutorial assumes you have:
We'll build security in layers, the way real production systems do. Each section is independent — you can apply them to an existing project or follow along from scratch.
If you're starting fresh, scaffold a minimal Express API:
mkdir secure-api && cd secure-api
npm init -y
npm install express
npm install -D nodemon
Create src/index.js:
import express from 'express';
const app = express();
app.use(express.json());
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(3000, () => console.log('API running on port 3000'));
Update package.json:
{
"type": "module",
"scripts": {
"dev": "nodemon src/index.js"
}
}
Now let's secure it — one layer at a time.
Never hardcode secrets. API keys, database URLs, JWT secrets, and service credentials should live in environment variables, not in your source code.
npm install dotenv
Create a .env file at the project root:
PORT=3000
NODE_ENV=development
# JWT
JWT_SECRET=your-256-bit-secret-here
JWT_EXPIRES_IN=15m
# Database
DATABASE_URL=postgres://user:password@localhost:5432/mydb
# API Keys
THIRD_PARTY_API_KEY=sk-...
Create a src/config.js module that validates required variables at startup — fail fast rather than crash later:
// src/config.js
import 'dotenv/config';
const required = ['JWT_SECRET', 'DATABASE_URL'];
for (const key of required) {
if (!process.env[key]) {
console.error(`❌ Missing required environment variable: ${key}`);
process.exit(1);
}
}
export const config = {
port: parseInt(process.env.PORT ?? '3000', 10),
nodeEnv: process.env.NODE_ENV ?? 'development',
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN ?? '15m',
},
databaseUrl: process.env.DATABASE_URL,
};
Import this at the top of src/index.js:
import './config.js'; // validates env vars before anything else
.gitignore rules# .gitignore
.env
.env.local
.env.production
.env*.local
Never commit
.envfiles. Use.env.example(with dummy values and no secrets) as documentation for other developers.
HTTP response headers are a free, low-effort layer of defense against a wide class of attacks: XSS, clickjacking, MIME sniffing, and more. Helmet sets secure defaults for all of them.
npm install helmet
import helmet from 'helmet';
app.use(helmet());
That single line sets 14+ security headers. Here's what's happening under the hood:
| Header | What it does |
|---|---|
Content-Security-Policy | Restricts which scripts, styles, and resources can load |
X-Frame-Options | Blocks clickjacking (embedding your app in iframes) |
X-Content-Type-Options | Prevents MIME-type sniffing |
Strict-Transport-Security | Forces HTTPS on supporting browsers |
X-DNS-Prefetch-Control | Controls DNS prefetching leakage |
Referrer-Policy | Controls what's sent in the Referer header |
Permissions-Policy | Restricts browser features (camera, mic, etc.) |
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'https://cdn.jsdelivr.net'],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
})
);
Tip: For pure JSON APIs that never serve HTML, the default CSP is fine as-is. Tighten it if you serve any frontend assets.
By default, browsers block JavaScript from calling your API if it's hosted on a different origin. CORS headers tell the browser which origins are allowed.
Getting CORS wrong is common: too permissive (*) exposes your API to abuse; too restrictive breaks legitimate clients.
npm install cors
import cors from 'cors';
const allowedOrigins = [
'https://yourdomain.com',
'https://app.yourdomain.com',
// Allow localhost in development only
...(process.env.NODE_ENV === 'development'
? ['http://localhost:3000', 'http://localhost:5173']
: []),
];
app.use(
cors({
origin: (origin, callback) => {
// Allow requests with no origin (mobile apps, curl, Postman)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error(`Origin ${origin} not allowed by CORS`));
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // allow cookies / Authorization headers
maxAge: 86400, // cache preflight for 24 hours
})
);
Some endpoints (e.g., public webhooks) may need different CORS rules. Apply cors() as route-level middleware:
const publicCors = cors({ origin: '*', methods: ['POST'] });
app.post('/webhooks/stripe', publicCors, stripeWebhookHandler);
Without rate limiting, your API is vulnerable to:
npm install express-rate-limit
Apply a generous baseline to all routes:
import rateLimit from 'express-rate-limit';
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200, // 200 requests per window
standardHeaders: 'draft-7', // Return RateLimit headers
legacyHeaders: false,
message: {
error: 'Too many requests, please try again later.',
},
});
app.use(globalLimiter);
Authentication routes need aggressive limiting — these are the highest-value targets for attackers:
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // 10 attempts per window
skipSuccessfulRequests: true, // don't count successful logins
message: {
error: 'Too many login attempts. Please wait 15 minutes.',
},
});
app.post('/auth/login', authLimiter, loginHandler);
app.post('/auth/register', authLimiter, registerHandler);
app.post('/auth/forgot-password', authLimiter, forgotPasswordHandler);
The default in-memory store doesn't work correctly when you have multiple server instances (containers, load-balanced nodes). Use Redis:
npm install rate-limit-redis ioredis
import RedisStore from 'rate-limit-redis';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 200,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
}),
});
JSON Web Tokens are the industry standard for stateless API authentication. Done correctly, they're secure and scalable. Done wrong, they're a liability.
npm install jsonwebtoken bcryptjs
A JWT has three parts: header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header (base64)
.eyJ1c2VySWQiOiIxMjMiLCJpYXQiOjE2... ← payload (base64, NOT encrypted)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV... ← signature (HMAC-SHA256)
Important: The payload is base64-encoded, not encrypted. Never put sensitive data (passwords, PII, payment info) in a JWT payload. It's readable by anyone who has the token.
// src/auth/tokens.js
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
export function signAccessToken(userId, role) {
return jwt.sign(
{
sub: userId, // subject: the user's ID
role,
iat: Math.floor(Date.now() / 1000),
},
config.jwt.secret,
{
expiresIn: config.jwt.expiresIn, // '15m' — keep access tokens short-lived
algorithm: 'HS256',
issuer: 'your-api-name',
}
);
}
export function signRefreshToken(userId) {
return jwt.sign(
{ sub: userId },
config.jwt.secret,
{
expiresIn: '7d', // refresh tokens live longer
algorithm: 'HS256',
}
);
}
// src/auth/login.js
import bcrypt from 'bcryptjs';
import { signAccessToken, signRefreshToken } from './tokens.js';
export async function loginHandler(req, res) {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
// Use constant-time comparison — bcrypt handles this
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
// Always return the same message — don't reveal whether the email exists
return res.status(401).json({ error: 'Invalid credentials' });
}
const accessToken = signAccessToken(user.id, user.role);
const refreshToken = signRefreshToken(user.id);
// Store refresh token hash in DB for rotation/revocation
await db.refreshTokens.create({
userId: user.id,
tokenHash: await bcrypt.hash(refreshToken, 10),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
res.json({ accessToken, refreshToken });
}
// src/middleware/authenticate.js
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
export function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed token' });
}
const token = authHeader.slice(7); // remove 'Bearer '
try {
const payload = jwt.verify(token, config.jwt.secret, {
algorithms: ['HS256'], // explicitly allow only HS256
issuer: 'your-api-name',
});
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// Role-based authorization
export function authorize(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user?.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
Using it on protected routes:
import { authenticate, authorize } from './middleware/authenticate.js';
app.get('/profile', authenticate, profileHandler);
app.delete('/users/:id', authenticate, authorize('admin'), deleteUserHandler);
alg field — prevent the alg: none exploitlocalStorage — use httpOnly cookies for web appsNever trust user input. Unvalidated input is the root cause of SQL injection, NoSQL injection, path traversal, and a long list of other attacks. Validate at the boundary — before any business logic runs.
npm install zod
Zod provides schema-based validation with full TypeScript inference. It's clean, composable, and errors are developer-friendly.
// src/schemas/user.schema.js
import { z } from 'zod';
export const registerSchema = z.object({
email: z.string().email().toLowerCase().trim(),
password: z
.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Must contain an uppercase letter')
.regex(/[0-9]/, 'Must contain a number')
.regex(/[^A-Za-z0-9]/, 'Must contain a special character'),
name: z.string().min(1).max(100).trim(),
});
export const updateProfileSchema = z.object({
name: z.string().min(1).max(100).trim().optional(),
bio: z.string().max(500).trim().optional(),
}).strict(); // reject unknown keys — important!
// src/middleware/validate.js
import { ZodError } from 'zod';
export function validate(schema) {
return (req, res, next) => {
try {
// Replace req.body with the parsed (sanitized) output
req.body = schema.parse(req.body);
next();
} catch (err) {
if (err instanceof ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: err.errors.map((e) => ({
field: e.path.join('.'),
message: e.message,
})),
});
}
next(err);
}
};
}
Using it on routes:
import { validate } from './middleware/validate.js';
import { registerSchema } from './schemas/user.schema.js';
app.post('/auth/register',
authLimiter,
validate(registerSchema),
registerHandler
);
const paginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export function validateQuery(schema) {
return (req, res, next) => {
try {
req.query = schema.parse(req.query);
next();
} catch (err) {
if (err instanceof ZodError) {
return res.status(400).json({ error: 'Invalid query parameters' });
}
next(err);
}
};
}
app.get('/posts', authenticate, validateQuery(paginationSchema), getPostsHandler);
Here's a complete, production-ready src/index.js incorporating every layer:
// src/index.js
import './config.js'; // ← validates env vars first
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
const app = express();
// ── Security headers ──────────────────────────────────────────
app.use(helmet());
// ── CORS ──────────────────────────────────────────────────────
const allowedOrigins = process.env.NODE_ENV === 'production'
? ['https://yourdomain.com']
: ['http://localhost:3000', 'http://localhost:5173'];
app.use(cors({
origin: (origin, cb) =>
!origin || allowedOrigins.includes(origin)
? cb(null, true)
: cb(new Error('Not allowed by CORS')),
credentials: true,
}));
// ── Body parsing ──────────────────────────────────────────────
app.use(express.json({ limit: '10kb' })); // cap payload size
// ── Global rate limit ─────────────────────────────────────────
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 200,
standardHeaders: 'draft-7',
legacyHeaders: false,
}));
// ── Routes ────────────────────────────────────────────────────
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
app.use('/auth', authRoutes);
app.use('/users', userRoutes);
// ── Global error handler ──────────────────────────────────────
app.use((err, req, res, next) => {
const isDev = process.env.NODE_ENV === 'development';
const status = err.status ?? 500;
console.error(err);
res.status(status).json({
error: isDev ? err.message : 'Internal server error',
...(isDev && { stack: err.stack }),
});
});
app.listen(config.port, () =>
console.log(`✅ API running on port ${config.port}`)
);
Use this before every deployment:
Environment Variables
✅ All secrets in .env (never hardcoded)
✅ .env is in .gitignore
✅ .env.example committed with dummy values
✅ App validates required env vars at startup
HTTP Headers (Helmet)
✅ helmet() applied globally
✅ CSP configured (if serving HTML)
✅ HSTS enabled in production
CORS
✅ Explicit allowlist of origins (no wildcard * in production)
✅ Credentials mode correct for your clients
✅ Methods and headers restricted to what's needed
Rate Limiting
✅ Global limiter on all routes
✅ Strict limiter on auth endpoints
✅ Redis store for multi-instance deployments
JWT
✅ Short-lived access tokens (≤15 min)
✅ Refresh token rotation implemented
✅ Algorithm explicitly whitelisted (no alg: none)
✅ No sensitive data in payload
✅ Tokens stored in httpOnly cookies for web apps
Validation
✅ All request bodies validated with schema
✅ Query params and URL params validated
✅ .strict() used to reject extra fields
✅ Error messages don't leak internal details
General
✅ express.json({ limit: '10kb' }) to cap payloads
✅ Error handler never exposes stack traces in production
✅ Passwords hashed with bcrypt (cost factor ≥ 12)
✅ Constant-time comparisons for secrets (bcrypt handles this)
This guide covers the essential security surface of a Node.js API. Once these layers are solid, consider:
npm audit, Snyk, or GitHub Dependabot for dependency vulnerabilities.env files to a secrets manager (AWS Secrets Manager, HashiCorp Vault, Doppler).Security isn't a checkbox — it's a continuous practice. But a well-layered API with Helmet, rate limiting, validated JWTs, schema validation, and properly scoped CORS is already in better shape than the majority of APIs running in production today.
Ship it with confidence.
Related: OWASP Cheat Sheet: REST Security · JWT Best Practices (RFC 8725) · express-rate-limit docs

Create elementAI Explainer Videos That Convert With Simple Text Prompts.
Learn More