DEV Community

Atlas Whoff
Atlas Whoff

Posted on

HTTP Security Headers for Next.js: CSP, HSTS, and Getting an A on securityheaders.com

Why Security Headers Are Your Fastest Win

Most security improvements take weeks to implement.
HTTP security headers take 20 minutes and cover a significant attack surface.

The Headers That Matter

// next.config.js
const securityHeaders = [
  // Prevent clickjacking
  { key: 'X-Frame-Options', value: 'DENY' },

  // Block MIME type sniffing
  { key: 'X-Content-Type-Options', value: 'nosniff' },

  // Referrer policy (don't leak URLs)
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },

  // Force HTTPS (HSTS)
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },

  // Permissions policy (disable unnecessary browser features)
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },

  // Content Security Policy
  {
    key: 'Content-Security-Policy',
    value: [
      "default-src 'self'",
      "script-src 'self' 'unsafe-eval' 'unsafe-inline'", // unsafe-* needed for Next.js dev
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' blob: data: https:",
      "font-src 'self'",
      "object-src 'none'",
      "base-uri 'self'",
      "form-action 'self'",
      "frame-ancestors 'none'",
      "upgrade-insecure-requests",
    ].join('; ')
  }
]

module.exports = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Content Security Policy Deep Dive

CSP prevents:
  - XSS (cross-site scripting) by restricting script sources
  - Data injection by restricting where content loads from
  - Clickjacking via frame-ancestors
  - Protocol downgrade attacks via upgrade-insecure-requests

The tricky part: Next.js needs 'unsafe-inline' for styles and
'unsafe-eval' for some optimizations. Mitigate with nonces.
Enter fullscreen mode Exit fullscreen mode

CSP with Nonces (More Secure)

// middleware.ts -- generate nonce per request
import { NextRequest, NextResponse } from 'next/server'
import { nanoid } from 'nanoid'

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(nanoid()).toString('base64')
  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    "img-src 'self' blob: data: https:",
    "object-src 'none'",
    "base-uri 'self'",
    "frame-ancestors 'none'",
  ].join('; ')

  const response = NextResponse.next()
  response.headers.set('Content-Security-Policy', csp)
  response.headers.set('x-nonce', nonce) // Pass to layout
  return response
}

// app/layout.tsx -- use nonce on scripts
import { headers } from 'next/headers'

export default function RootLayout({ children }) {
  const nonce = headers().get('x-nonce')
  return (
    <html>
      <head>
        <script nonce={nonce} src="/my-script.js" />
      </head>
      <body>{children}</body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

Testing Your Headers

# Check headers from terminal
curl -I https://yourapp.com

# Online scanners:
# securityheaders.com -- grades your headers A-F
# observatory.mozilla.org -- Mozilla's scanner
# developer.chrome.com/docs/lighthouse -- Lighthouse audit

# Target: A rating on securityheaders.com
Enter fullscreen mode Exit fullscreen mode

Headers for API Routes

// API routes need different headers than pages
{
  source: '/api/(.*)',
  headers: [
    { key: 'X-Content-Type-Options', value: 'nosniff' },
    { key: 'X-Frame-Options', value: 'DENY' },
    // No CSP needed for API routes -- they return JSON, not HTML
    // CORS headers added separately per route
  ]
}
Enter fullscreen mode Exit fullscreen mode

Pre-Configured in the AI SaaS Starter Kit

Security headers configured in next.config.js with sensible defaults for a SaaS app. A-grade on securityheaders.com out of the box.

$99 one-time at whoffagents.com

Top comments (0)