·6 min read

How to Secure Your Node.js API — JWT, Rate Limiting, and the Mistakes Everyone Makes

I audited a client's API and found their admin endpoint had no auth. They're not alone. Here are the 5 security must-haves every Node.js API needs.

SecurityNode.jsBackendBest Practices

I audited a client's API last month. They had a production app with real users, real payments, real data. Their admin endpoint — the one that could delete users and refund payments — had no authentication. None. Anyone who found the URL could access it.

They're not alone. Most Node.js APIs I review have at least one critical security gap. Not because the developers are bad — because security isn't taught well, and the tutorials skip it.

Here are the 5 security must-haves for any production Node.js API. If you're missing any of these, fix them today.

The 5 must-haves

  1. Authentication (JWT done right)
  2. Rate limiting (protect against brute force)
  3. CORS (don't let any website call your API)
  4. Input validation (never trust the client)
  5. Security headers (the ones nobody sets)

Let's go through each one.

1. JWT authentication — done right

JWT (JSON Web Tokens) is the standard for API authentication. But most implementations have a critical flaw: they store tokens in localStorage.

Why that's bad: localStorage is accessible to any JavaScript running on your page. One XSS vulnerability — a third-party script, a compromised dependency, a malicious ad — and attackers steal every user's token. Game over.

The fix: store JWTs in httpOnly cookies. JavaScript can't read them. XSS can't steal them.

// Bad — vulnerable to XSS
localStorage.setItem('token', jwtToken)
fetch('/api/data', {
  headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
})
 
// Good — httpOnly cookie, XSS-proof
res.cookie('token', jwtToken, {
  httpOnly: true,      // JavaScript can't access it
  secure: true,        // HTTPS only
  sameSite: 'strict',  // CSRF protection
  maxAge: 86400000     // 24 hours
})
// Browser sends the cookie automatically — no manual header
fetch('/api/data', { credentials: 'include' })

On the server side, verify the JWT on protected routes:

import jwt from 'jsonwebtoken'
 
function authMiddleware(req, res, next) {
  const token = req.cookies.token
  if (!token) return res.status(401).json({ error: 'Not authenticated' })
 
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET)
    req.userId = payload.userId
    next()
  } catch {
    res.status(401).json({ error: 'Invalid token' })
  }
}

The mistakes everyone makes:

  • Using a weak JWT secret (secret123). Use a 32+ character random string. Store it in an environment variable.
  • Never expiring tokens. Set maxAge and implement refresh tokens for long sessions.
  • Putting sensitive data in the JWT payload. JWTs are encoded, not encrypted — anyone with the token can read the payload.

2. Rate limiting — stop brute force attacks

Without rate limiting, someone can hit your login endpoint 10,000 times per second trying passwords. Eventually, they get in.

Rate limiting caps how many requests an IP can make in a time window. The simplest setup uses express-rate-limit:

import rateLimit from 'express-rate-limit'
 
// Global limiter — 100 requests per 15 minutes per IP
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  message: 'Too many requests, try again later'
})
app.use(limiter)
 
// Stricter limiter for auth routes — 5 attempts per 15 minutes
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: 'Too many login attempts, try again in 15 minutes'
})
app.post('/api/login', authLimiter, loginHandler)

The mistake everyone makes: applying one global rate limit. Your login endpoint needs a much stricter limit than your public data endpoints. Set different limits per route type:

  • Auth routes (login, register, password reset): 5 requests / 15 minutes
  • Public data (product listings, search): 100 requests / 15 minutes
  • Write operations (creating orders, posting comments): 20 requests / 15 minutes
  • File uploads: 10 requests / hour

Pro tip: use a Redis store for rate limiting in production. The default in-memory store doesn't work across multiple server instances.

3. CORS — don't let any website call your API

CORS (Cross-Origin Resource Sharing) controls which websites can make requests to your API. The most dangerous misconfiguration:

// Bad — lets ANY website call your API
const cors = require('cors')
app.use(cors({ origin: '*' }))

This means a malicious website can make authenticated requests to your API using a logged-in user's cookies. If you handle payments or sensitive data, that's a disaster.

The fix: whitelist your own domains.

app.use(cors({
  origin: [
    'https://mefjudev.com',
    'https://www.mefjudev.com',
    // Development only
    process.env.NODE_ENV === 'development' && 'http://localhost:3000'
  ].filter(Boolean),
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization']
}))

When origin: '*' is okay: public APIs with no authentication, no sensitive data, and no write operations. If your API has any of those, never use wildcard CORS.

4. Input validation — never trust the client

The rule: every piece of data that enters your API is an attack vector until proven safe. Never trust what the client sends.

// Bad — assumes the client sends valid data
app.post('/api/users', (req, res) => {
  const { email, age } = req.body
  db.query('INSERT INTO users (email, age) VALUES (?, ?)', [email, age])
})
 
// Good — validates everything
import { z } from 'zod'
 
const userSchema = z.object({
  email: z.string().email(),
  age: z.number().int().min(13).max(120),
  name: z.string().min(1).max(100)
})
 
app.post('/api/users', (req, res) => {
  const result = userSchema.safeParse(req.body)
  if (!result.success) {
    return res.status(400).json({ error: 'Invalid input', details: result.error })
  }
  const { email, age, name } = result.data
  // Safe to use — validated
})

Why this matters beyond security: validation catches bugs. If your code expects age to be a number and the client sends a string, you get weird behavior, not a crash. Validation makes failures explicit and early.

The validation checklist:

  • Strings: length limits, character whitelists (especially for filenames, usernames)
  • Numbers: type, range, integer vs float
  • Enums: only allow predefined values
  • Dates: valid format, sensible range
  • File uploads: type, size, content inspection

Never use raw user input in SQL queries or shell commands. Use parameterized queries (always) and avoid exec()/spawn() with user input entirely.

5. Security headers — the ones nobody sets

Security headers are HTTP response headers that tell the browser to enforce security policies. Most APIs don't set them. They should.

import helmet from 'helmet'
 
// Sets all the important headers at once
app.use(helmet())

Helmet sets these headers automatically:

  • X-Content-Type-Options: nosniff — prevents MIME-type sniffing
  • X-Frame-Options: DENY — prevents clickjacking
  • Strict-Transport-Security — forces HTTPS
  • Content-Security-Policy — controls what resources can load
  • X-XSS-Protection — browser XSS filter

One line of code, massive security improvement. There's no reason not to do this.

The security checklist

Before your API touches production:

  • JWT stored in httpOnly cookies (not localStorage)
  • Strong JWT secret (32+ random characters)
  • Token expiration + refresh tokens
  • Rate limiting on all routes (stricter on auth)
  • CORS whitelisted to your domains only
  • Input validation on every endpoint (use Zod, Joi, or similar)
  • Parameterized SQL queries (no string concatenation)
  • Helmet for security headers
  • HTTPS enforced (use a reverse proxy or your host's SSL)
  • Error messages don't leak stack traces in production
  • Secrets in environment variables (never in code)
  • Dependencies scanned for known vulnerabilities (npm audit)

Run through this list before every launch. The client API I audited at the start? They were missing 7 of these. It took an afternoon to fix all of them.

The honest truth about API security

Security isn't a feature you add at the end. It's a discipline you apply to every endpoint, every input, every deployment. The 5 must-haves above take a few hours to implement and prevent 95% of common attacks.

The remaining 5% — advanced threats, zero-days, social engineering — that's what monitoring, logging, and incident response plans are for. But if you haven't done the basics, the advanced stuff doesn't matter.

Most API breaches aren't sophisticated hacks. They're developers who skipped authentication on an endpoint, used origin: '*', or trusted client input. Don't be that developer.

Need a secure API built right?

I build production-grade APIs with authentication, rate limiting, validation, and proper security headers — from day one, not as an afterthought. Let's talk.