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.
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.
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.
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 HTTP — includeSubDomains 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.