Skip to content

Security

Hardening a Node.js REST API: The Checklist I Actually Use

Validation, rate limiting, headers, NoSQL injection, error leakage and CORS. Nine concrete fixes that take an afternoon and remove most of the ways a public API gets abused.

3 min read0 views
  • #nodejs
  • #express
  • #security
  • #api
  • #backend

Every API I've shipped started the same way: routes that work, no guards. That's fine on localhost. The moment it's public, a handful of very boring attacks start arriving within hours — mostly automated, mostly looking for the defaults.

This is the checklist I run before anything goes live. None of it is clever. All of it matters.

1. Validate at the boundary, once

The most common vulnerability isn't exotic — it's trusting the request body. Validate at the edge and let the rest of the code assume clean data:

lib/validate.ts
import { z } from 'zod';
 
export const createPostSchema = z.object({
  title: z.string().trim().min(3).max(200),
  content: z.string().min(1).max(100_000),
  tags: z.array(z.string().trim().toLowerCase()).max(8).default([]),
  published: z.boolean().default(false),
});
 
export type CreatePostInput = z.infer<typeof createPostSchema>;
app/api/posts/route.ts
export async function POST(req: Request) {
  const parsed = createPostSchema.safeParse(await req.json().catch(() => null));
 
  if (!parsed.success) {
    return NextResponse.json(
      { error: 'Invalid request', issues: parsed.error.flatten().fieldErrors },
      { status: 400 }
    );
  }
 
  const post = await Blog.create(parsed.data); // known-good shape
  return NextResponse.json({ data: post }, { status: 201 });
}

Two things you get free: the schema strips unknown keys, so nobody sets role: "admin" by adding it to a body; and z.infer gives you types that can't drift from the runtime check.

2. Rate limit before authentication

Login endpoints get hammered. Rate limiting after auth is too late — the expensive bcrypt comparison has already run.

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
 
const limiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '60 s'),
  prefix: 'rl:login',
});
 
export async function POST(req: Request) {
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown';
  const { success, reset } = await limiter.limit(ip);
 
  if (!success) {
    return NextResponse.json(
      { error: 'Too many attempts' },
      { status: 429, headers: { 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)) } }
    );
  }
 
  // …verify credentials
}

Use a shared store, not in-memory counters — on serverless, in-memory state resets on every cold start, which means no limit at all. Tier your limits: 5/min on login, 60/min on reads, 10/min on writes.

3. Never build a query from raw user input

MongoDB doesn't have SQL injection, but it does have operator injection. If req.body.email is { "$ne": null }, this matches the first user in the collection:

// ✗ An object here becomes a query operator
const user = await User.findOne({ email: req.body.email });
 
// ✓ Coerce to the type you expect
const email = String(req.body.email ?? '').toLowerCase().trim();
const user = await User.findOne({ email });

Schema validation (step 1) fixes this globally, because z.string() rejects objects outright. That's the real argument for validating everything, not just the fields that look dangerous.

Regex from user input needs escaping too, or a search box becomes a CPU exhaustion vector:

const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const rx = new RegExp(escape(query), 'i');

4. Set the security headers

One middleware, most of the browser-side protections:

middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
 
export function middleware(_req: NextRequest) {
  const res = NextResponse.next();
 
  res.headers.set('X-Content-Type-Options', 'nosniff');
  res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  res.headers.set('X-Frame-Options', 'DENY');
  res.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
  res.headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
 
  return res;
}
 
export const config = { matcher: '/:path*' };

On Express, app.use(helmet()) does the equivalent. A Content-Security-Policy is the higher-value addition, but it needs real tuning per app — do it deliberately rather than copying someone else's.

5. Make CORS explicit

Access-Control-Allow-Origin: * combined with credentials is the classic mistake — and browsers reject that combination anyway, which is why people then "fix" it by reflecting the origin. Don't. Use an allowlist:

const ALLOWED = new Set([process.env.APP_URL, 'https://adityabhavar.me']);
 
const origin = req.headers.get('origin');
if (origin && ALLOWED.has(origin)) {
  res.headers.set('Access-Control-Allow-Origin', origin);
  res.headers.set('Vary', 'Origin');
  res.headers.set('Access-Control-Allow-Credentials', 'true');
}

The Vary: Origin header is not optional — without it a CDN will happily cache one origin's response and serve it to another.

6. Don't leak internals in errors

Stack traces and driver messages tell an attacker your schema, your ORM and sometimes your file paths:

// ✗ error.message can be "E11000 duplicate key error … index: email_1"
catch (error) {
  return NextResponse.json({ error: error.message }, { status: 500 });
}
 
// ✓ Log the detail, return a generic message plus a correlation id
catch (error) {
  const id = crypto.randomUUID();
  console.error({ id, error });
  return NextResponse.json({ error: 'Internal error', ref: id }, { status: 500 });
}

The ref is genuinely useful — a user can quote it in a bug report and you can find the exact log line.

Same principle on login: "Invalid email or password" for both cases. Distinguishing them turns your login form into a user-enumeration endpoint.

7. Cookies over localStorage

If a token lives in localStorage, any XSS reads it. httpOnly cookies are unreadable from JavaScript:

cookies().set('token', jwt, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
  path: '/',
  maxAge: 60 * 60 * 24 * 7,
});

sameSite: 'lax' blocks the common CSRF shapes while keeping normal navigation working. If you need sameSite: 'none' for a cross-site setup, you need real CSRF tokens too.

8. Keep secrets out of the bundle

In Next.js, anything prefixed NEXT_PUBLIC_ is inlined into client JavaScript. It's not a secret. It's a published constant.

DATABASE_URL=            # server only
JWT_SECRET=              # server only
NEXT_PUBLIC_SITE_URL=    # shipped to the browser, and that's fine

Fail fast at startup rather than at 3 a.m.:

const required = ['MONGODB_URI', 'JWT_SECRET'];
for (const key of required) {
  if (!process.env[key]) throw new Error(`Missing required env var: ${key}`);
}

And check git log -p -- .env* once. If a secret was ever committed, rotate it — removing the file doesn't remove the history.

9. Cap the payload

An endpoint that accepts a 50 MB JSON body is a free denial-of-service:

const MAX_BYTES = 100_000;
 
const length = Number(req.headers.get('content-length') ?? 0);
if (length > MAX_BYTES) {
  return NextResponse.json({ error: 'Payload too large' }, { status: 413 });
}

On Express: app.use(express.json({ limit: '100kb' })). The default is 100 KB, so this mostly matters when someone has raised it.

The pre-launch pass

  • Every endpoint validates its input with a schema
  • Auth endpoints rate limited by IP, in a shared store
  • No user input reaches a query as an object
  • Security headers set in middleware
  • CORS allowlisted, with Vary: Origin
  • Errors generic outward, detailed in logs, correlated by id
  • Tokens in httpOnly cookies
  • npm audit clean, secrets never committed
  • Body size capped

An afternoon of work. It won't stop a determined, targeted attacker — but it removes essentially everything the automated traffic is looking for, and that's the traffic you'll actually get.