Back to Engineering Guides

Securing Node.js REST APIs: JWT, Rate Limiting, and OWASP Top 10

Technical Insight
Published December 1, 2025
Securing Node.js REST APIs: JWT, Rate Limiting, and OWASP Top 10

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:

  1. Broken Object Level Authorization: Always verify the authenticated user owns the requested resource, not just that they're authenticated
  2. Broken Authentication: Short-lived tokens, httpOnly cookies, refresh token rotation (covered above)
  3. Broken Object Property Level Authorization: Strip sensitive fields from responses — use explicit allowlists, not blocklists
  4. Mass Assignment: Never spread req.body directly into database operations; use validated schemas
  5. Unrestricted Resource Consumption: Rate limiting, payload size limits (express.json({ limit: '10kb' })), query depth limits for GraphQL
  6. 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.

Distribute Knowledge

#Security#Node.js#API#Backend