Skip to main content
Technology & EngineeringSecurity Ratelimit431 lines

Security Headers

Security headers with Helmet.js, Content Security Policy, CORS configuration, CSRF protection, rate limiting patterns, and Next.js security headers

Quick Summary28 lines
Security headers are the first line of defense between your application and the browser. They instruct the browser to enforce constraints — which scripts can run, which origins can embed your content, whether cookies travel over plain HTTP. Helmet.js applies sensible defaults for Express-based servers. For Next.js, you configure headers directly in `next.config.js` or middleware. The goal is defense in depth: CSP prevents XSS, CORS controls cross-origin access, CSRF tokens guard state-changing requests, and rate limiting caps abuse. None of these replace proper input validation, but together they dramatically shrink the attack surface.

## Key Points

- Start with `Content-Security-Policy-Report-Only` to identify violations before enforcing. Review reports for at least a week before switching to enforcing mode.
- Set `Strict-Transport-Security` with `includeSubDomains` and `preload` on production. Submit your domain to the HSTS preload list.
- Use `SameSite=Strict` or `SameSite=Lax` on all cookies. Combine with `HttpOnly` and `Secure` flags.
- Apply rate limits per route type: strict on auth endpoints, moderate on API endpoints, lenient on static assets.
- Use `Permissions-Policy` to explicitly disable browser features you do not use (camera, microphone, geolocation).
- For CORS, never use `origin: "*"` with `credentials: true`. Browsers will reject this combination, and it signals a misconfiguration.
- Use nonces rather than `'unsafe-inline'` for CSP script-src when possible. This is the strongest XSS mitigation available via headers.
- **Setting `unsafe-inline` and `unsafe-eval` in CSP and calling it done.** These directives effectively disable CSP protection against XSS. Use nonces or hashes instead.
- **Applying CORS with `origin: "*"` in production.** This allows any website to make requests to your API. Always whitelist specific origins.
- **Skipping CSRF protection because "we use JWTs".** If the JWT is stored in a cookie (common for SSR apps), CSRF attacks are still possible. Token-in-header or SameSite cookies are needed.
- **Rate limiting only on the application layer behind a load balancer.** Each instance sees a fraction of traffic. Use a shared store (Redis) or an edge rate limiter.
- **Setting security headers only on HTML pages.** API responses also need CORS, rate limit headers, and content type protection. Apply headers globally and customize per route.

## Quick Example

```typescript
// For Express/Node.js
npm install helmet cors csrf-csrf cookie-parser express-rate-limit

// For Next.js (no extra deps for headers; optional for CSRF)
npm install csrf-csrf cookie-parser
```
skilldb get security-ratelimit-skills/Security HeadersFull skill: 431 lines
Paste into your CLAUDE.md or agent config

Security Headers and HTTP Protection

Core Philosophy

Security headers are the first line of defense between your application and the browser. They instruct the browser to enforce constraints — which scripts can run, which origins can embed your content, whether cookies travel over plain HTTP. Helmet.js applies sensible defaults for Express-based servers. For Next.js, you configure headers directly in next.config.js or middleware. The goal is defense in depth: CSP prevents XSS, CORS controls cross-origin access, CSRF tokens guard state-changing requests, and rate limiting caps abuse. None of these replace proper input validation, but together they dramatically shrink the attack surface.

Setup

Installation

// For Express/Node.js
npm install helmet cors csrf-csrf cookie-parser express-rate-limit

// For Next.js (no extra deps for headers; optional for CSRF)
npm install csrf-csrf cookie-parser

Express Base Configuration

// server.ts
import express from "express";
import helmet from "helmet";
import cors from "cors";
import cookieParser from "cookie-parser";
import rateLimit from "express-rate-limit";

const app = express();

app.use(helmet());
app.use(cookieParser());
app.use(express.json());

app.listen(3000);

Key Techniques

Helmet.js Default Headers

// Helmet sets these headers by default:
// Content-Security-Policy: default-src 'self'
// Cross-Origin-Opener-Policy: same-origin
// Cross-Origin-Resource-Policy: same-origin
// Origin-Agent-Cluster: ?1
// Referrer-Policy: no-referrer
// Strict-Transport-Security: max-age=15552000; includeSubDomains
// X-Content-Type-Options: nosniff
// X-DNS-Prefetch-Control: off
// X-Download-Options: noopen
// X-Frame-Options: SAMEORIGIN
// X-Permitted-Cross-Domain-Policies: none
// X-XSS-Protection: 0

import helmet from "helmet";

app.use(helmet());

Content Security Policy (CSP)

// Customizing CSP for a typical web app
import helmet from "helmet";

app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", "'strict-dynamic'"],
        styleSrc: ["'self'", "'unsafe-inline'"],
        imgSrc: ["'self'", "data:", "https://cdn.example.com"],
        fontSrc: ["'self'", "https://fonts.gstatic.com"],
        connectSrc: ["'self'", "https://api.example.com"],
        frameSrc: ["'none'"],
        objectSrc: ["'none'"],
        baseUri: ["'self'"],
        formAction: ["'self'"],
        upgradeInsecureRequests: [],
      },
    },
  })
);

CSP with Nonce for Inline Scripts

// middleware for generating per-request nonce
import crypto from "crypto";
import helmet from "helmet";
import { Request, Response, NextFunction } from "express";

app.use((req: Request, res: Response, next: NextFunction) => {
  res.locals.cspNonce = crypto.randomBytes(16).toString("base64");
  next();
});

app.use((req: Request, res: Response, next: NextFunction) => {
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'", `'nonce-${res.locals.cspNonce}'`],
        styleSrc: ["'self'", `'nonce-${res.locals.cspNonce}'`],
      },
    },
  })(req, res, next);
});

// In your template/response:
// <script nonce="<%= cspNonce %>">...</script>

CORS Configuration

// lib/cors.ts
import cors from "cors";

const allowedOrigins = [
  "https://app.example.com",
  "https://admin.example.com",
];

export const corsMiddleware = cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error("Not allowed by CORS"));
    }
  },
  methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
  allowedHeaders: ["Content-Type", "Authorization", "X-Request-Id"],
  exposedHeaders: ["X-RateLimit-Remaining", "X-RateLimit-Reset"],
  credentials: true,
  maxAge: 86400, // preflight cache 24h
});

app.use(corsMiddleware);

CSRF Protection

// lib/csrf.ts
import { doubleCsrf } from "csrf-csrf";
import cookieParser from "cookie-parser";
import express from "express";

const app = express();
app.use(cookieParser(process.env.COOKIE_SECRET));

const {
  generateToken,
  doubleCsrfProtection,
} = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET!,
  cookieName: "__csrf",
  cookieOptions: {
    httpOnly: true,
    sameSite: "strict",
    secure: process.env.NODE_ENV === "production",
    path: "/",
  },
  size: 64,
  getTokenFromRequest: (req) => req.headers["x-csrf-token"] as string,
});

// Route to get CSRF token
app.get("/api/csrf-token", (req, res) => {
  const token = generateToken(req, res);
  res.json({ token });
});

// Apply CSRF protection to state-changing routes
app.use("/api", doubleCsrfProtection);

app.post("/api/transfer", (req, res) => {
  // CSRF token is validated automatically
  res.json({ success: true });
});

Express Rate Limiting

// lib/rate-limit.ts
import rateLimit from "express-rate-limit";

// Global rate limit
export const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  limit: 100,
  standardHeaders: "draft-7",
  legacyHeaders: false,
  message: { error: "Too many requests, please try again later" },
  keyGenerator: (req) => {
    return req.headers["x-forwarded-for"] as string || req.ip || "unknown";
  },
});

// Strict limit for auth routes
export const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 5,
  standardHeaders: "draft-7",
  legacyHeaders: false,
  message: { error: "Too many login attempts" },
  skipSuccessfulRequests: true,
});

// Usage
app.use(globalLimiter);
app.use("/api/auth", authLimiter);

Next.js Security Headers

// next.config.ts
import type { NextConfig } from "next";

const securityHeaders = [
  {
    key: "Content-Security-Policy",
    value: [
      "default-src 'self'",
      "script-src 'self' 'unsafe-eval' 'unsafe-inline'",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: blob: https://cdn.example.com",
      "font-src 'self' https://fonts.gstatic.com",
      "connect-src 'self' https://api.example.com",
      "frame-ancestors 'none'",
      "base-uri 'self'",
      "form-action 'self'",
    ].join("; "),
  },
  { key: "X-Frame-Options", value: "DENY" },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
  {
    key: "Strict-Transport-Security",
    value: "max-age=63072000; includeSubDomains; preload",
  },
];

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: securityHeaders,
      },
    ];
  },
};

export default nextConfig;

Next.js Middleware CORS

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

const allowedOrigins = ["https://app.example.com", "https://admin.example.com"];

export function middleware(req: NextRequest) {
  const origin = req.headers.get("origin") ?? "";
  const isAllowed = allowedOrigins.includes(origin);

  // Handle preflight
  if (req.method === "OPTIONS") {
    return new NextResponse(null, {
      status: 204,
      headers: {
        "Access-Control-Allow-Origin": isAllowed ? origin : "",
        "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
        "Access-Control-Allow-Headers": "Content-Type, Authorization",
        "Access-Control-Allow-Credentials": "true",
        "Access-Control-Max-Age": "86400",
      },
    });
  }

  const response = NextResponse.next();

  if (isAllowed) {
    response.headers.set("Access-Control-Allow-Origin", origin);
    response.headers.set("Access-Control-Allow-Credentials", "true");
  }

  return response;
}

export const config = {
  matcher: ["/api/:path*"],
};

CSP Report-Only Mode

// Test a new CSP without breaking anything
const reportOnlyHeaders = [
  {
    key: "Content-Security-Policy-Report-Only",
    value: [
      "default-src 'self'",
      "script-src 'self'",
      "style-src 'self'",
      "report-uri /api/csp-report",
    ].join("; "),
  },
];

// Endpoint to collect violations
// app/api/csp-report/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const report = await req.json();
  console.warn("CSP Violation:", JSON.stringify(report, null, 2));

  // In production, send to your logging service
  return NextResponse.json({ received: true });
}

Combined Security Middleware for Express

// lib/security.ts
import helmet from "helmet";
import cors from "cors";
import rateLimit from "express-rate-limit";
import { doubleCsrf } from "csrf-csrf";
import cookieParser from "cookie-parser";
import { Express } from "express";

export function applySecurityMiddleware(app: Express) {
  // Parse cookies first (required for CSRF)
  app.use(cookieParser(process.env.COOKIE_SECRET));

  // Helmet with custom CSP
  app.use(
    helmet({
      contentSecurityPolicy: {
        directives: {
          defaultSrc: ["'self'"],
          scriptSrc: ["'self'"],
          styleSrc: ["'self'", "'unsafe-inline'"],
          imgSrc: ["'self'", "data:"],
          connectSrc: ["'self'"],
          frameSrc: ["'none'"],
          objectSrc: ["'none'"],
        },
      },
    })
  );

  // CORS
  app.use(
    cors({
      origin: process.env.ALLOWED_ORIGINS?.split(",") ?? [],
      credentials: true,
    })
  );

  // Global rate limit
  app.use(
    rateLimit({
      windowMs: 15 * 60 * 1000,
      limit: 200,
      standardHeaders: "draft-7",
      legacyHeaders: false,
    })
  );

  // CSRF for non-GET routes
  const { doubleCsrfProtection, generateToken } = doubleCsrf({
    getSecret: () => process.env.CSRF_SECRET!,
    cookieName: "__csrf",
    cookieOptions: {
      httpOnly: true,
      sameSite: "strict",
      secure: process.env.NODE_ENV === "production",
    },
    getTokenFromRequest: (req) => req.headers["x-csrf-token"] as string,
  });

  app.get("/api/csrf-token", (req, res) => {
    res.json({ token: generateToken(req, res) });
  });

  app.use("/api", doubleCsrfProtection);
}

Best Practices

  • Start with Content-Security-Policy-Report-Only to identify violations before enforcing. Review reports for at least a week before switching to enforcing mode.
  • Set Strict-Transport-Security with includeSubDomains and preload on production. Submit your domain to the HSTS preload list.
  • Use SameSite=Strict or SameSite=Lax on all cookies. Combine with HttpOnly and Secure flags.
  • Apply rate limits per route type: strict on auth endpoints, moderate on API endpoints, lenient on static assets.
  • Use Permissions-Policy to explicitly disable browser features you do not use (camera, microphone, geolocation).
  • For CORS, never use origin: "*" with credentials: true. Browsers will reject this combination, and it signals a misconfiguration.
  • Use nonces rather than 'unsafe-inline' for CSP script-src when possible. This is the strongest XSS mitigation available via headers.

Anti-Patterns

  • Setting unsafe-inline and unsafe-eval in CSP and calling it done. These directives effectively disable CSP protection against XSS. Use nonces or hashes instead.
  • Applying CORS with origin: "*" in production. This allows any website to make requests to your API. Always whitelist specific origins.
  • Skipping CSRF protection because "we use JWTs". If the JWT is stored in a cookie (common for SSR apps), CSRF attacks are still possible. Token-in-header or SameSite cookies are needed.
  • Rate limiting only on the application layer behind a load balancer. Each instance sees a fraction of traffic. Use a shared store (Redis) or an edge rate limiter.
  • Disabling Helmet defaults to "fix" broken functionality. If a header breaks your app, understand why before removing it. Usually the fix is adding an exception, not removing the header entirely.
  • Setting security headers only on HTML pages. API responses also need CORS, rate limit headers, and content type protection. Apply headers globally and customize per route.
  • Forgetting to set X-Content-Type-Options: nosniff. Without it, browsers may MIME-sniff responses and execute files as scripts that you intended to serve as data.

Install this skill directly: skilldb add security-ratelimit-skills

Get CLI access →