Security vulnerabilities in REST APIs account for the majority of production data breaches. Unlike frontend bugs, a single API security flaw can expose your entire database. This guide focuses on the practical implementation details — not theory — of securing a Node.js API against the most common attack vectors.
JWT: What Most Implementations Get Wrong
JSON Web Tokens are widely used and widely misunderstood. The most common mistakes:
Storing JWTs in localStorage
localStorage is accessible to any JavaScript on the page, making it vulnerable to XSS. Store access tokens in memory and refresh tokens in httpOnly cookies:
// Set refresh token as httpOnly cookie (inaccessible to JS)
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
// Access token returned in response body — stored in memory by client
res.json({ accessToken });
Not Validating the Algorithm
The alg: none attack forges tokens by setting the algorithm to none. Always specify the expected algorithm explicitly:
import jwt from 'jsonwebtoken';
// WRONG: accepts whatever algorithm the token claims
const payload = jwt.verify(token, secret);
// CORRECT: reject tokens with unexpected algorithms
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });
Long-Lived Access Tokens
Access tokens should expire in 15 minutes. Use a refresh token rotation pattern:
const ACCESS_TOKEN_TTL = '15m';
const REFRESH_TOKEN_TTL = '7d';
async function refreshTokens(oldRefreshToken: string) {
// Verify old refresh token
const payload = jwt.verify(oldRefreshToken, process.env.REFRESH_SECRET, { algorithms: ['HS256'] });
// Check token is in the allowlist (implement refresh token rotation)
const stored = await redis.get(`refresh:${payload.sub}`);
if (stored !== oldRefreshToken) throw new Error('Token reuse detected — account flagged');
// Issue new tokens
const accessToken = jwt.sign({ sub: payload.sub }, process.env.ACCESS_SECRET, { expiresIn: ACCESS_TOKEN_TTL });
const newRefresh = jwt.sign({ sub: payload.sub }, process.env.REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_TTL });
// Store new refresh token, invalidate old
await redis.set(`refresh:${payload.sub}`, newRefresh, 'EX', 7 * 24 * 3600);
return { accessToken, refreshToken: newRefresh };
}
Rate Limiting: Protecting Against Credential Stuffing
Credential stuffing attacks hammer your login endpoint with millions of username/password combinations. A simple per-IP rate limit is insufficient — attackers distribute across thousands of IPs. Layer your defenses:
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
// 1. Per-IP global limit (stops most bots)
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
store: new RedisStore({ client: redis }),
}));
// 2. Tighter limit on auth endpoints
const authLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour window
max: 10, // 10 attempts per IP per hour
skipSuccessfulRequests: true,
store: new RedisStore({ client: redis, prefix: 'rl:auth:' }),
});
app.post('/api/auth/login', authLimiter, loginHandler);
// 3. Per-account lockout (IP-independent)
async function checkAccountLockout(email: string) {
const attempts = await redis.incr(`lockout:${email}`);
if (attempts === 1) await redis.expire(`lockout:${email}`, 900); // 15 min window
if (attempts > 5) throw new TooManyAttemptsError();
}
Input Validation with Zod
Never trust user input. Validate at the API boundary with a schema library:
import { z } from 'zod';
const CreateOrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive().max(100),
})).min(1).max(50),
shippingAddress: z.object({
line1: z.string().min(5).max(200).trim(),
city: z.string().min(2).max(100).trim(),
postcode: z.string().regex(/^[A-Z0-9 -]{3,10}$/i),
country: z.enum(['IN', 'US', 'GB', 'AU']),
}),
promoCode: z.string().regex(/^[A-Z0-9-]{4,20}$/).optional(),
});
app.post('/api/orders', async (req, res) => {
const result = CreateOrderSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.flatten() });
}
const order = result.data; // Fully typed and validated
// ...
});
Note the regex on promoCode: it whitelists allowed characters rather than blacklisting bad ones — always the safer approach.
SQL Injection in Raw Queries
ORM users often overlook raw query injection. When you must use raw SQL:
// VULNERABLE: string interpolation
const rows = await db.query(`
SELECT * FROM users WHERE email = '${userEmail}'
`);
// SAFE: parameterized query — the DB driver handles escaping
const rows = await db.query(
'SELECT * FROM users WHERE email = $1',
[userEmail]
);
// Prisma raw query — also parameterized
const users = await prisma.$queryRaw`
SELECT * FROM users WHERE email = ${userEmail}
`;
Security Headers
Add security headers with helmet:
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'nonce-{nonce}'"],
imgSrc: ["'self'", 'data:', 'https:'],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
}));
OWASP API Security Top 10: Checklist
The critical items for most Node.js APIs:
- Broken Object Level Authorization: Always verify the authenticated user owns the requested resource, not just that they're authenticated
- Broken Authentication: Short-lived tokens, httpOnly cookies, refresh token rotation (covered above)
- Broken Object Property Level Authorization: Strip sensitive fields from responses — use explicit allowlists, not blocklists
- Mass Assignment: Never spread
req.bodydirectly into database operations; use validated schemas - Unrestricted Resource Consumption: Rate limiting, payload size limits (
express.json({ limit: '10kb' })), query depth limits for GraphQL - Security Logging: Log authentication failures, authorization failures, and input validation failures with IP and user context — but never log passwords or tokens
Security is iterative. Run OWASP ZAP or Burp Suite against your staging API before each major release. Add security headers, validate all inputs, keep dependencies updated (npm audit in CI), and treat every external input as hostile until proven otherwise.