Setting Security Headers in Edge Middleware

This guide is part of Implementing Early Returns in Edge Middleware. It builds one header-set function that runs on every response, covers the five headers that actually change your attack surface, and makes the nonce-versus-cacheability trade-off explicit rather than accidental.

Security headers are the cheapest defence you will ever deploy: no dependency, no latency, a handful of bytes on the wire, and they turn several whole classes of vulnerability from exploitable into inert. They are also the easiest thing to get subtly wrong, because a Content-Security-Policy that is almost right fails open — the browser applies it, nothing breaks, and the one directive you fumbled leaves the hole you were trying to close.

The problem

The usual trajectory: you ship a CSP, the site breaks, you add 'unsafe-inline' to make it work again, and now the policy no longer stops the attack it was written for — inline script injection is exactly what 'unsafe-inline' permits. The header is still there, still green in the scanner, and completely decorative.

The second failure is architectural. Headers get set in three places — the framework, the CDN configuration, and the edge function — and they disagree. One layer sets Referrer-Policy: no-referrer-when-downgrade, another sets strict-origin-when-cross-origin, and which one the browser sees depends on which path the response took. Static assets served straight from cache get no headers at all, because the layer that adds them only runs on dynamic routes.

The third is the one that shows up in your bill rather than your scanner: you correctly add a per-request nonce, and your cache hit ratio goes to zero overnight, because every response now contains a unique string and cannot be shared between users.

Root cause: the edge is the only layer that sees every response and can still vary per request

Think about where a header can be set. The origin application knows the most — it knows which scripts are on the page — but it never sees responses served from CDN cache, so anything cached bypasses it entirely. The CDN configuration sees every response but is static: it cannot compute a fresh nonce per request, because it has no execution context. Edge middleware is the only layer with both properties. It runs on the request path for every response the platform serves, including cached ones, and it is real code that can generate a random value.

That is the argument for putting the header set here, and it is also where the caching tension comes from. A nonce is by definition unique per response. A response containing a unique value is, by definition, not shareable. Content-Security-Policy with a nonce and Cache-Control: public, s-maxage=3600 are contradictory instructions, and the browser will happily honour both — serving a cached HTML document whose inline scripts carry last hour’s nonce, which the freshly generated header no longer permits, so the page renders blank.

There are only two coherent resolutions. Either the HTML is dynamic and uncacheable at shared tiers, and you use nonces. Or the HTML is cacheable, and you use hashes — 'sha256-...' digests of your inline script content — which are stable across responses and therefore cache-compatible.

Which layer can set the headers Three rows compare the origin application, the CDN configuration and edge middleware across two properties: whether the layer sees cached responses, and whether it can compute a value that differs per request. Only edge middleware satisfies both. Layer Sees cached responses Can vary per request Origin application knows the page contents no yes CDN configuration static rules, no execution yes no Edge middleware code on every request path yes yes Only the bottom row can emit a per-request nonce on a response the origin never rendered.
The origin cannot cover cached responses and the CDN cannot compute a nonce. The intersection is why this belongs in middleware.

Step 1 — Generate a nonce

A CSP nonce must be unpredictable and unique per response. Web Crypto is available in every edge runtime, so this needs no dependency and costs nothing measurable.

// lib/nonce.ts
/**
 * 16 random bytes, base64-encoded. The CSP specification requires at least
 * 128 bits of entropy; anything derived from the request (a hash of the URL,
 * a timestamp, a counter) is predictable and therefore not a nonce.
 */
export function createNonce(): string {
  const bytes = new Uint8Array(16);
  crypto.getRandomValues(bytes);
  let binary = '';
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return btoa(binary);
}

btoa is present in all three edge runtimes; Buffer is not, which is why the byte-to-string loop is written out rather than reaching for a Node API. The same constraint is covered generally in replacing Node Buffer with Uint8Array at the edge.

Step 2 — Build the header set

One builder, one place to change a directive, one thing to test.

// lib/security-headers.ts
export interface HeaderSetOptions {
  /** Present for HTML documents using nonce-based CSP; omit for hash mode. */
  nonce?: string;
  /** Stable hashes of inline scripts, used when the document must stay cacheable. */
  scriptHashes?: readonly string[];
  /** Report-only mode while you shake out violations on a new policy. */
  reportOnly?: boolean;
  /** Endpoint path that collects violation reports. Must be same-origin. */
  reportTo?: string;
}

export function buildSecurityHeaders(opts: HeaderSetOptions = {}): Headers {
  const headers = new Headers();

  headers.set(
    opts.reportOnly ? 'content-security-policy-report-only' : 'content-security-policy',
    buildCsp(opts),
  );

  // Two years, all subdomains, eligible for browser preloading. Only ship
  // includeSubDomains once every subdomain genuinely serves HTTPS.
  headers.set('strict-transport-security', 'max-age=63072000; includeSubDomains; preload');

  // Stops the browser second-guessing Content-Type. Without it, a user-uploaded
  // file served as text/plain can be sniffed into executable script.
  headers.set('x-content-type-options', 'nosniff');

  // Send the full URL to our own origin, only the bare origin cross-site, and
  // nothing at all when downgrading to HTTP.
  headers.set('referrer-policy', 'strict-origin-when-cross-origin');

  // Deny the powerful APIs outright. An empty allow-list is a hard denial,
  // including for embedded third-party frames.
  headers.set(
    'permissions-policy',
    [
      'accelerometer=()',
      'camera=()',
      'geolocation=()',
      'gyroscope=()',
      'magnetometer=()',
      'microphone=()',
      'payment=()',
      'usb=()',
      'interest-cohort=()',
    ].join(', '),
  );

  // Modern replacements for X-Frame-Options and the legacy XSS auditor.
  headers.set('cross-origin-opener-policy', 'same-origin');
  headers.set('cross-origin-resource-policy', 'same-origin');
  headers.set('x-frame-options', 'DENY');

  return headers;
}

function buildCsp(opts: HeaderSetOptions): string {
  const scriptSources = ["'self'"];
  if (opts.nonce) {
    scriptSources.push(`'nonce-${opts.nonce}'`);
    // strict-dynamic lets a nonced loader script pull in its own dependencies
    // without every CDN host being enumerated. Modern browsers then ignore
    // the host allow-list entirely, which is the point.
    scriptSources.push("'strict-dynamic'");
  }
  for (const hash of opts.scriptHashes ?? []) scriptSources.push(`'${hash}'`);

  const directives = [
    "default-src 'self'",
    `script-src ${scriptSources.join(' ')}`,
    // Styles are the pragmatic exception: many frameworks inject inline styles
    // and style injection is a far weaker primitive than script injection.
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: blob:",
    "font-src 'self'",
    "connect-src 'self'",
    "frame-ancestors 'none'",
    "base-uri 'none'",
    "form-action 'self'",
    "object-src 'none'",
    'upgrade-insecure-requests',
  ];

  if (opts.reportTo) directives.push(`report-uri ${opts.reportTo}`);
  return directives.join('; ');
}

Three directives in that list do more work than the rest combined. base-uri 'none' blocks an injected <base> tag from re-pointing every relative script URL at an attacker’s host — a bypass that defeats an otherwise perfect script-src. object-src 'none' kills the plugin-based execution paths that predate CSP. And frame-ancestors 'none' is the actually-standardised clickjacking control; X-Frame-Options is there only for clients that never implemented it.

Note what is absent: 'unsafe-inline' in script-src. If adding it is the only way to make the site work, the policy is not protecting you, and the correct response is to move the inline scripts to nonced or hashed ones rather than to keep the header as decoration.

Anatomy of the policy A Content-Security-Policy string is broken into its directives, each annotated with the specific attack it blocks, from injected script execution through base tag hijacking to clickjacking. script-src 'self' 'nonce-r4Kx…' 'strict-dynamic' only scripts we stamped may run base-uri 'none' injected base tag cannot re-point scripts object-src 'none' closes legacy plugin execution frame-ancestors 'none' clickjacking, standardised form-action 'self' injected form cannot post credentials away script-src … 'unsafe-inline' re-permits the exact attack — never ship
The last row is the one that shows up in real policies. Adding it to silence console errors undoes every row above it.

Step 3 — Pass the nonce to the renderer

The nonce has to reach two places: the response header, and every inline <script> tag in the HTML. Middleware can set the header directly, but it cannot rewrite the markup for free, so it forwards the nonce to the renderer on a request header and the framework reads it there.

// middleware.ts
import { createNonce } from './lib/nonce';
import { buildSecurityHeaders } from './lib/security-headers';

const HTML_ROUTES = /^\/(?!api\/)/;

export default async function middleware(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const isDocument = HTML_ROUTES.test(url.pathname);

  // No nonce on API routes: there is no markup to stamp, and adding one would
  // make otherwise-cacheable JSON responses unique per request.
  const nonce = isDocument ? createNonce() : undefined;

  const forwarded = new Headers(request.headers);
  // Strip first: a client must never be able to choose its own nonce.
  forwarded.delete('x-nonce');
  if (nonce) forwarded.set('x-nonce', nonce);

  const response = await fetch(new Request(request, { headers: forwarded }));

  const secured = new Headers(response.headers);
  for (const [name, value] of buildSecurityHeaders({ nonce })) {
    secured.set(name, value);
  }

  if (nonce) {
    // A nonced document is unique to this response and must not be shared.
    secured.set('cache-control', 'private, no-store');
  }

  return new Response(response.body, {
    status: response.status,
    statusText: response.statusText,
    headers: secured,
  });
}

export const config = {
  runtime: 'edge',
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

The forwarded.delete('x-nonce') before the set is not decorative. If a client can send x-nonce and your renderer trusts it, an attacker chooses a nonce, gets it stamped into the markup, and knows a value the CSP will accept — which is the entire security property of a nonce, handed over. The same hygiene applies to every trusted header you inject; see debugging header conflicts in edge middleware.

Streaming the original response.body through rather than buffering it keeps time-to-first-byte unchanged. Constructing a new Response around the existing body is the cheap way to replace headers; reading the body into a string is what turns a header rewrite into a latency regression.

Step 4 — Choose nonces or hashes deliberately

This is the decision the whole page exists for. Nonce mode and hash mode are both correct CSP; they differ entirely in what they cost you at the cache tier.

Nonce mode. Every response carries a unique value, so the HTML cannot be stored at a shared tier and replayed. Setting cache-control: private, no-store is not a limitation you are working around — it is the honest description of what the response is. Use it for authenticated, personalised documents that were never cacheable anyway. The cost is zero, because you had nothing to lose.

Hash mode. You compute 'sha256-...' digests of each inline script’s exact byte content at build time and list them in the policy. The header is then identical for every user, so the document caches normally at every tier. The cost is discipline: the hash must match the script body byte for byte, so any build step that reformats, minifies differently, or injects a variable into an inline script invalidates it and the script silently stops running.

The wrong answer is a nonce plus s-maxage. The shared tier stores one nonce and serves it to everyone; the browser receives HTML whose inline scripts carry a nonce that the cached header may or may not still match. When it does not, the page renders and its scripts do nothing, which presents as an intermittent blank page that reproduces for nobody.

Nonce versus hash at the shared tier In nonce mode every user receives a unique document that the shared tier must not store, so every request reaches the renderer. In hash mode the policy is identical for all users and the tier serves a single cached copy. Nonce mode — personalised, no-store user A user B shared tier bypassed renderer, every request nonce-aB1… / nonce-Qz9… Hash mode — shareable, cacheable user A user B shared tier HIT renderer, once sha256-… identical
The dashed arrow runs once per TTL. That single difference is why the nonce decision belongs to whoever owns the cache hit ratio.

Local versus production divergence

Behaviour Local dev Edge production
Strict-Transport-Security Ignored by browsers over http://localhost Enforced, and cached by the browser for max-age
Shared cache tier Absent, so a nonce plus s-maxage looks fine Present, and the mismatch surfaces as blank pages
Inline script hashes Match your dev build’s exact bytes Must match the production build’s bytes after minification
upgrade-insecure-requests No effect; everything is already HTTP Rewrites subresource URLs to HTTPS
CSP violation reports Visible in your own console Only visible if report-uri points somewhere you read
crypto.getRandomValues Present in Node 18+ and Miniflare Present natively
Framework dev overlay Injects its own inline scripts, which your CSP blocks Absent from the production bundle

The last row causes the most wasted time. A framework’s development error overlay and hot-reload client inject inline scripts your policy correctly refuses, so the CSP looks broken locally and is fine in production. Gate the strict policy on the environment rather than loosening it for everyone.

Validating with Vitest

The builder is a pure function returning a Headers object, so every assertion is a direct string check with no runtime emulation.

// security-headers.test.ts
import { describe, expect, it } from 'vitest';
import { buildSecurityHeaders } from './lib/security-headers';
import { createNonce } from './lib/nonce';

const csp = (h: Headers) => h.get('content-security-policy') ?? '';

describe('buildSecurityHeaders', () => {
  it('never permits unsafe-inline in script-src', () => {
    const directive = csp(buildSecurityHeaders({ nonce: 'abc' }))
      .split('; ')
      .find((d) => d.startsWith('script-src'));
    expect(directive).not.toContain("'unsafe-inline'");
  });

  it('includes the nonce in script-src when one is supplied', () => {
    expect(csp(buildSecurityHeaders({ nonce: 'abc123' }))).toContain("'nonce-abc123'");
  });

  it('omits any nonce token when none is supplied', () => {
    expect(csp(buildSecurityHeaders())).not.toContain('nonce-');
  });

  it('locks down the directives that defeat script-src bypasses', () => {
    const policy = csp(buildSecurityHeaders({ nonce: 'abc' }));
    expect(policy).toContain("base-uri 'none'");
    expect(policy).toContain("object-src 'none'");
    expect(policy).toContain("frame-ancestors 'none'");
    expect(policy).toContain("form-action 'self'");
  });

  it('sets HSTS for two years across subdomains', () => {
    const value = buildSecurityHeaders().get('strict-transport-security');
    expect(value).toBe('max-age=63072000; includeSubDomains; preload');
  });

  it('sets nosniff and a referrer policy that does not leak paths cross-site', () => {
    const h = buildSecurityHeaders();
    expect(h.get('x-content-type-options')).toBe('nosniff');
    expect(h.get('referrer-policy')).toBe('strict-origin-when-cross-origin');
  });

  it('denies the powerful APIs in Permissions-Policy', () => {
    const value = buildSecurityHeaders().get('permissions-policy') ?? '';
    for (const feature of ['camera', 'microphone', 'geolocation', 'payment', 'usb']) {
      expect(value).toContain(`${feature}=()`);
    }
  });

  it('switches to report-only without changing the policy itself', () => {
    const enforced = buildSecurityHeaders({ nonce: 'x' });
    const reporting = buildSecurityHeaders({ nonce: 'x', reportOnly: true });
    expect(reporting.get('content-security-policy')).toBeNull();
    expect(reporting.get('content-security-policy-report-only')).toBe(csp(enforced));
  });
});

describe('createNonce', () => {
  it('produces a fresh value on every call', () => {
    const values = new Set(Array.from({ length: 500 }, () => createNonce()));
    expect(values.size).toBe(500);
  });

  it('produces at least 128 bits of base64-encoded entropy', () => {
    expect(createNonce()).toMatch(/^[A-Za-z0-9+/]{22}==$/);
  });
});

The first test is the important one, and it is worth explaining in a comment in your own repo. 'unsafe-inline' gets added under deadline pressure by someone chasing a console error, and a failing test with a clear name is the cheapest possible intervention at that moment.

Common pitfalls and resolutions

The site breaks and adding 'unsafe-inline' fixes it — that flag re-permits exactly the injection the policy exists to stop. Move the inline scripts to nonced or hashed ones instead, and deploy the new policy in report-only mode first.

Intermittent blank pages that nobody can reproduce — a nonced document is being cached at a shared tier and served with a stale nonce. Either set no-store on nonced responses or switch to hashes.

Cache hit ratio collapsed after shipping CSP — a nonce is being generated for every route, including cacheable ones. Restrict nonce generation to personalised HTML documents.

Inline script stopped running after a build change — a hash no longer matches the script’s bytes. Regenerate hashes as part of the build rather than pasting them by hand.

HSTS locked out a subdomain that only serves HTTPincludeSubDomains applies to every subdomain immediately and the browser caches it for the full max-age. Ship a short max-age first, confirm every subdomain is HTTPS, then raise it.

Headers appear on dynamic routes but not on static assets — the matcher excludes the asset paths. Decide deliberately: assets need nosniff and HSTS even though they need no CSP.

Attacker-supplied nonce accepted — the middleware set x-nonce without deleting an inbound value first. Always strip trusted headers before setting them.

Production deployment checklist

Frequently Asked Questions

Why set security headers at the edge instead of in the application?

Because the origin application never sees responses served from CDN cache, so anything cached bypasses its headers entirely. CDN configuration covers every response but cannot compute a value that changes per request, which rules out nonces. Edge middleware is the only layer with both properties.

Does a CSP nonce make my pages uncacheable?

At shared tiers, yes. A nonce is unique per response by definition, so a document containing one cannot be stored and replayed to another user. If the document must stay cacheable, use stable hashes of your inline scripts instead, which produce an identical policy for every visitor.

What actually goes wrong if I cache a nonced page anyway?

The shared tier stores one nonce and serves it to everyone, while the header the browser applies may carry a different value. When they disagree the page renders but its inline scripts never execute, which presents as an intermittent blank page that reproduces for nobody and disappears when the cache entry expires.

Is adding unsafe-inline to script-src ever acceptable?

No. That flag re-permits exactly the inline script injection the policy exists to prevent, leaving a header that passes scanners while protecting nothing. If the site only works with it, the fix is to move the inline scripts to nonced or hashed ones, deploying the new policy in report-only mode first.

Which directives matter beyond script-src?

Three. base-uri set to none stops an injected base tag re-pointing every relative script URL at an attacker’s host, which defeats an otherwise perfect script-src. object-src set to none closes legacy plugin execution paths. frame-ancestors set to none is the standardised clickjacking control that supersedes X-Frame-Options.

How should I roll out Strict-Transport-Security safely?

Start with a short max-age of a few minutes and without includeSubDomains, confirm nothing breaks, then confirm every subdomain genuinely serves HTTPS before widening. Only add preload once you are certain, because browsers cache the directive for the full max-age and a subdomain that is HTTP-only becomes unreachable until it expires.