Skip to main content
All articles
Technology

Hardening a Next.js App: Rate Limiting, Headers and Audit Trails

Key takeaways

  • In-memory rate limiting works for low-traffic forms but resets on cold start — know the tradeoff before relying on it.
  • A strict Content-Security-Policy in Next.js requires nonces; report-only mode first saves you from breaking production.
  • Never trust a single IP header on a serverless platform — the correct one depends on your host.
  • Audit logs matter more than prevention once something goes wrong.
  • The most valuable protection is usually the simplest: server-side validation of everything.

Most Next.js security advice stops at "use environment variables" and "validate input". Useful, but it skips the parts that actually get exploited: unprotected API routes, forms that can be hammered, and the absence of any record of what happened.

This article covers the measures I apply to production apps, with the tradeoffs stated honestly. Examples target Netlify and Vercel, but the reasoning transfers.

Contents
  1. Rate limiting on serverless
  2. Security headers
  3. Session handling
  4. Audit logging
  5. Input validation
  6. A word on dependencies
  7. Checklist
  8. Summary

Rate limiting on serverless

Any public endpoint that costs you money or sends email needs a limit. Contact forms, search endpoints, anything that touches a paid API.

The honest problem with serverless: there is no persistent process to hold state in. Function instances start, handle requests, and get recycled. Anything in memory disappears.

In-memory: simple, imperfect

For a low-traffic contact form, an in-memory sliding window is often adequate:

// lib/rate-limit.ts
type Entry = { timestamps: number[] };
const store = new Map<string, Entry>();

const WINDOW_MS = 60 * 60 * 1000; // 1 hour
const MAX_REQUESTS = 5;

export function checkRateLimit(key: string): {
  allowed: boolean;
  remaining: number;
  resetAt: number;
} {
  const now = Date.now();
  const entry = store.get(key) ?? { timestamps: [] };

  // Drop timestamps outside the window
  entry.timestamps = entry.timestamps.filter(t => now - t < WINDOW_MS);

  const allowed = entry.timestamps.length < MAX_REQUESTS;

  if (allowed) {
    entry.timestamps.push(now);
    store.set(key, entry);
  }

  // Opportunistic cleanup to bound memory
  if (store.size > 10_000) {
    for (const [k, v] of store) {
      if (v.timestamps.every(t => now - t >= WINDOW_MS)) {
        store.delete(k);
      }
    }
  }

  return {
    allowed,
    remaining: Math.max(0, MAX_REQUESTS - entry.timestamps.length),
    resetAt: (entry.timestamps[0] ?? now) + WINDOW_MS,
  };
}

What this gives you: protection against casual abuse — someone hitting submit repeatedly, a naive script.

What it does not give you: protection against a determined attacker. Each function instance has its own memory. Spread requests across enough concurrent instances and the limit effectively multiplies. Cold starts reset it entirely.

Be clear-eyed about which problem you are solving. For a contact form that sends five emails a week, in-memory is proportionate. For an authentication endpoint, it is not.

Persistent: correct, more setup

When the limit actually matters, use shared storage. Upstash Redis has a free tier and an official rate-limiting library:

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '1 h'),
  analytics: true,
});

export async function POST(request: Request) {
  const ip = getClientIp(request);
  const { success, remaining, reset } = await ratelimit.limit(ip);

  if (!success) {
    return Response.json(
      { error: 'Too many requests' },
      {
        status: 429,
        headers: {
          'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)),
          'X-RateLimit-Remaining': String(remaining),
        },
      }
    );
  }

  // handle request
}

Netlify Blobs works similarly if you prefer staying within the platform.

Getting the client IP right

A detail that breaks silently. Header precedence differs by platform, and trusting the wrong one lets an attacker spoof their identity by setting the header themselves.

function getClientIp(request: Request): string {
  const headers = request.headers;

  // Platform-specific headers first — these are set by the
  // infrastructure and cannot be spoofed by the client
  const netlifyIp = headers.get('x-nf-client-connection-ip');
  if (netlifyIp) return netlifyIp;

  const vercelIp = headers.get('x-real-ip');
  if (vercelIp) return vercelIp;

  // x-forwarded-for is a client-controllable list.
  // The leftmost entry is the original client IP *if*
  // your infrastructure appends rather than trusts.
  const forwarded = headers.get('x-forwarded-for');
  if (forwarded) return forwarded.split(',')[0].trim();

  return 'unknown';
}

Check your host's documentation for which header is authoritative. Do not just take x-forwarded-for — on some setups a client can set it freely.

Security headers

Headers are the cheapest security you will ever add. Most take one line.

// next.config.ts
const securityHeaders = [
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=63072000; includeSubDomains; preload',
  },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  {
    key: 'Permissions-Policy',
    value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
  },
];

export default {
  async headers() {
    return [{ source: '/:path*', headers: securityHeaders }];
  },
};

Note on HSTS: preload is a commitment. Once your domain is on the preload list, browsers refuse plain HTTP for it, and removal takes months. Do not add preload until you are certain every subdomain has HTTPS.

Content-Security-Policy

CSP is the header that actually stops cross-site scripting, and the one most likely to break your app if you rush it.

Next.js injects inline scripts for hydration, which means 'unsafe-inline' or a nonce. Use a nonce:

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');

  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' blob: data:`,
    `font-src 'self'`,
    `connect-src 'self'`,
    `frame-ancestors 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
    `object-src 'none'`,
  ].join('; ');

  const headers = new Headers(request.headers);
  headers.set('x-nonce', nonce);

  const response = NextResponse.next({ request: { headers } });
  response.headers.set('Content-Security-Policy', csp);

  return response;
}

Two practical notes.

style-src 'unsafe-inline' is usually unavoidable — CSS-in-JS and Tailwind's runtime injection both need it. The security impact is far smaller than for scripts.

font-src 'self' only works if you self-host fonts. Which you should anyway: loading fonts from Google's servers transmits visitor IPs to a third party without consent, and German courts have ruled on this specifically.

Deploy in report-only mode first:

response.headers.set('Content-Security-Policy-Report-Only', csp);

Run it for a week, watch the console for violations, fix them, then switch to enforcing. Shipping a strict CSP straight to production is how you discover at 2am that your analytics script is blocked.

Session handling

If your app has authentication, three things matter more than the library you pick.

Cookie flags:

cookies().set('session', token, {
  httpOnly: true,      // not readable by JavaScript
  secure: true,        // HTTPS only
  sameSite: 'lax',     // CSRF mitigation
  path: '/',
  maxAge: 60 * 60 * 24 * 7,
});

httpOnly is the important one. A session token readable by JavaScript is a session token stealable by any XSS.

Password hashing. bcrypt with a cost factor of at least 12, or argon2id if your runtime supports it. Never SHA-256 — it is fast, which is exactly wrong for passwords.

Timing-safe comparison for tokens and secrets:

import { timingSafeEqual } from 'node:crypto';

function safeCompare(a: string, b: string): boolean {
  const bufA = Buffer.from(a);
  const bufB = Buffer.from(b);
  if (bufA.length !== bufB.length) return false;
  return timingSafeEqual(bufA, bufB);
}

String equality on secrets leaks length and prefix information through timing.

Audit logging

Prevention fails eventually. What determines how bad the outcome is: whether you can reconstruct what happened.

Log security-relevant events with enough context to investigate:

type AuditEvent = {
  timestamp: string;
  event: 'auth.failed' | 'auth.success' | 'ratelimit.exceeded' | 'admin.action';
  ip: string;
  userAgent: string | null;
  userId?: string;
  detail?: Record<string, unknown>;
};

export async function logAudit(event: AuditEvent) {
  const key = `audit/${event.timestamp}-${crypto.randomUUID()}`;
  await store.set(key, JSON.stringify(event));

  // Alert immediately on patterns worth waking up for
  if (event.event === 'auth.failed') {
    const recentFailures = await countRecent('auth.failed', event.ip, 15 * 60_000);
    if (recentFailures >= 10) {
      await sendAlert(`10+ failed logins from ${event.ip}`);
    }
  }
}

What to log: authentication attempts (success and failure), rate limit hits, administrative actions, permission denials, payment events.

What never to log: passwords, full session tokens, card numbers, or anything you would not want in a support ticket. Log the fact of the event, not the secret involved.

On Netlify, Blobs works for this at low volume. Beyond that, use a proper logging service — you want search and retention, not a bucket of JSON files.

Input validation

The oldest advice and still the most effective. Validate on the server, always, regardless of what the client does.

import { z } from 'zod';

const ContactSchema = z.object({
  name: z.string().trim().min(1).max(100),
  email: z.string().trim().email().max(254),
  message: z.string().trim().min(10).max(5000),
});

export async function POST(request: Request) {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return Response.json({ error: 'Invalid JSON' }, { status: 400 });
  }

  const parsed = ContactSchema.safeParse(body);
  if (!parsed.success) {
    return Response.json({ error: 'Validation failed' }, { status: 400 });
  }

  const { name, email, message } = parsed.data;
  // ...
}

Note the error response: "Validation failed", not the full Zod error tree. Detailed validation errors are useful in development and informative to attackers in production. Return a generic message to the client and log the detail server-side.

Same principle for authentication: "Invalid credentials", never "user not found" versus "wrong password". The distinction tells an attacker which usernames exist.

A word on dependencies

Most real-world compromises come through packages, not through code you wrote.

npm audit --audit-level=high

Run it in CI. Enable Dependabot or Renovate. And before adding a dependency, ask whether you need it — a package with 40 transitive dependencies to format a date is 40 more things that can be compromised.

Checklist

  • Every public API route has a rate limit
  • Client IP read from the platform-authoritative header
  • Security headers set (HSTS, nosniff, frame-options, referrer-policy)
  • CSP deployed, report-only first
  • Fonts self-hosted (security and GDPR)
  • Session cookies httpOnly, secure, sameSite
  • Passwords hashed with bcrypt cost ≥ 12 or argon2id
  • All input validated server-side with a schema
  • Error messages generic to the client, detailed in logs
  • Audit log for auth events and admin actions
  • Alerts on repeated failures
  • Secrets in environment variables, never committed
  • npm audit in CI

Summary

Security on serverless has a specific shape: no persistent process means no persistent state, which makes rate limiting harder than it looks and makes logging more important than usual.

The measures that give the most protection per hour spent are unglamorous. Server-side validation of everything. Correct cookie flags. Headers. A record of what happened.

The one thing worth being honest with yourself about: in-memory rate limiting is a speed bump, not a wall. If the endpoint matters, pay for persistent storage. If it does not, the speed bump is fine — just do not confuse the two.