Skip to content
Docs

How to add per-request CSP nonces to CDN-cached HTML on Vercel

Use Routing Middleware and a self-fetch to add a fresh CSP nonce to cached HTML without rendering the page again on every request.

Leo ReuterSolutions Architect

You can use a random nonce to authorize scripts in a strict Content Security Policy (CSP). The browser receives the nonce in both the Content-Security-Policy response header and each trusted <script> element. Because a nonce must be unpredictable and unique for every response, adding it during rendering makes the entire HTML document request-specific, which prevents the completed document from being reused by a CDN.

On Vercel, Routing Middleware runs before the CDN cache and cannot modify the body of the response that follows it. Middleware can work around this with a self-fetch: it fetches the same URL through the CDN, adds a fresh nonce to the cached HTML, and returns the transformed document to the visitor. This approach uses standard web APIs and works with any web framework deployed on Vercel.

Copy link to headingOverview

In this guide, you'll learn how to:

  • Keep the raw HTML document cacheable on Vercel's CDN
  • Retrieve that document through a protected middleware self-fetch
  • Add a fresh nonce and CSP header for every browser response
  • Optionally avoid repeated self-fetches with an in-memory cache
  • Use Runtime Cache when you need regional sharing or on-demand invalidation

Copy link to headingWhen to use this pattern

The middleware self-fetch and response transformation add implementation complexity and can increase TTFB, so only use this pattern when you need both:

Per-request noncesCDN-cached HTMLRecommended approach
YesYesThe self-fetch pattern in this guide
YesNoGenerate the nonce during rendering
NoYesHash-based CSP

Copy link to headingHow it works

The request passes through Routing Middleware twice:

  1. The browser request enters middleware.
  2. Middleware fetches the same URL with a secret internal header.
  3. The internal request re-enters middleware. After validating the secret, middleware lets it continue.
  4. Vercel returns the raw document from the CDN, rendering it only on a cache miss.
  5. The outer middleware request buffers the document, generates a nonce and adds it to every script.
  6. The transformed response is returned with a matching CSP header.

The two responses have different caching rules:

ResponseCache policyContains a nonce
Raw HTML (internal self-fetch)Public, stored by the CDNNo
Transformed HTML (browser response)private, no-store, never shared-cachedYes, unique per request

Copy link to headingImplement the self-fetch

First, make the raw HTML response cacheable. You can do this by setting an appropriate CDN cache policy, for example with Cache-Control or Vercel-CDN-Cache-Control with s-maxage, or by using your framework's native caching primitives such as ISR in Next.js or Nuxt. The raw HTML response must end up cached and served from Vercel's CDN.

The raw document must not contain a request-specific nonce. It must also be safe to share between every request represented by the CDN cache key.

Next, add the self-fetch to Routing Middleware. This example focuses on the request flow; adapt the matcher and HTML transformation to your application:

import { next } from '@vercel/functions';
const INTERNAL_HEADER = 'x-internal-render';
function isHtmlDocumentRequest(request: Request): boolean {
const destination = request.headers.get('sec-fetch-dest') ?? '';
const purpose = `${request.headers.get('purpose')} ${request.headers.get('sec-purpose')}`;
const accept = request.headers.get('accept') ?? '';
return request.method === 'GET' &&
['', 'document', 'frame', 'iframe'].includes(destination.toLowerCase()) &&
!/prefetch|prerender/i.test(purpose) &&
/text\/html|application\/xhtml\+xml/i.test(accept);
}
function isHtmlResponse(response: Response): boolean {
return (
(response.status < 300 || response.status >= 400) &&
(response.headers.get('content-type') ?? '')
.toLowerCase()
.includes('text/html')
);
}
function generateNonce(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return btoa(String.fromCharCode(...bytes));
}
function addNonceToScripts(html: string, nonce: string): string {
return html.replace(/<script\b([^>]*)>/gi, (_tag, attributes: string) => {
const withoutNonce = attributes.replace(
/\s+nonce=(?:"[^"]*"|'[^']*'|[^\s>]+)/gi,
'',
);
return `<script nonce="${nonce}"${withoutNonce}>`;
});
}
async function addFreshCspNonce(raw: Response): Promise<Response> {
const nonce = generateNonce();
const html = addNonceToScripts(await raw.text(), nonce);
const headers = new Headers(raw.headers);
headers.set(
'Content-Security-Policy',
`script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'self'`,
);
headers.set('Cache-Control', 'private, no-store');
for (const name of [
'CDN-Cache-Control',
'Vercel-CDN-Cache-Control',
'Vercel-Cache-Tag',
'Content-Length',
'Content-Encoding',
'ETag',
'Content-Digest',
'Digest',
]) {
headers.delete(name);
}
return new Response(html, {
status: raw.status,
statusText: raw.statusText,
headers,
});
}
export default async function middleware(request: Request) {
const secret = process.env.INTERNAL_RENDER_SECRET;
const marker = request.headers.get(INTERNAL_HEADER);
if (!secret) return new Response('Missing internal secret', { status: 500 });
if (marker !== null) {
return marker === secret ? next() : new Response('Forbidden', { status: 403 });
}
if (!isHtmlDocumentRequest(request)) return next();
const headers = new Headers(request.headers);
headers.set(INTERNAL_HEADER, secret);
// Never forward visitor credentials into the shared, cacheable request
for (const name of ['authorization', 'cookie']) {
headers.delete(name);
}
const raw = await fetch(request.url, {
headers,
redirect: 'manual',
});
return isHtmlResponse(raw) ? addFreshCspNonce(raw) : raw;
}

The internal fetch populates a shared CDN cache entry, so it must never carry visitor credentials. The example deletes the authorization and cookie headers before the self-fetch. Without this, a misconfigured page could render personalized markup into the shared cache and serve it to other visitors.

If Skew Protection is enabled, you can re-add only the __vdpl cookie so the internal request resolves to the same deployment; continue to strip every application cookie. Exclude authenticated and personalized routes from the transform entirely using the matcher.

The transformed response removes two groups of headers:

  • CDN cache headers (CDN-Cache-Control, Vercel-CDN-Cache-Control, Vercel-Cache-Tag): nonce-bearing documents must never be shared-cached
  • Body metadata (Content-Length, Content-Encoding, ETag, Content-Digest, Digest): these values describe the raw body and are no longer valid after the HTML changes

Before deploying, generate a secret with openssl rand -hex 32. Add it as INTERNAL_RENDER_SECRET under the project’s environment variables in every environment where the middleware runs.

This minimal addNonceToScripts() implementation works for conventional application-generated HTML. Use a standards-compliant HTML parser when processing arbitrary HTML because quoted attributes can contain > characters and break a regular-expression-based transform.

Configure the middleware matcher to exclude requests that cannot be HTML documents. The isHtmlDocumentRequest() check handles the remaining method and request header checks inside middleware.

For example, a Next.js application can exclude API routes, framework assets, metadata files, and prefetches before middleware runs:

export const config = {
matcher: [
{
source:
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' },
],
},
],
};

Add this configuration to your middleware.ts file. When using the native Next.js 16 Proxy convention instead, use proxy.ts and export the handler as proxy. The proxy function runs on the Node.js runtime only.

The secret header prevents infinite recursion. It must contain a high-entropy environment secret rather than a boolean marker. Reject an incorrect value and use redirect: 'manual' so the secret cannot follow a cross-origin redirect.

Copy link to headingBuild the CSP intentionally

The abbreviated policy in the example only shows the directives relevant to the pattern. Define your complete production CSP in one place, including the connect-src, img-src, frame-src, style-src, font-src, form-action and frame-ancestors requirements of your application.

With 'strict-dynamic', nonce-trusted scripts can load additional scripts through supported DOM APIs without maintaining a script-src host allowlist. This is useful for tag managers and other trusted script loaders.

Validate two common exceptions before enforcing the policy:

  • Scripts inserted through document.write() or another parser-inserted pattern do not inherit trust through 'strict-dynamic'. The loader must add the nonce to them or use a supported DOM API. Google Tag Manager provides a nonce-aware container that propagates the page nonce to scripts it adds.
  • eval(), new Function() and string-based timers require 'unsafe-eval' in script-src. Avoid it unless these APIs cannot be removed.

The transformer authorizes every script present in the raw document. Continue to sanitize untrusted and CMS-authored HTML before rendering it; otherwise, an injected script would also receive a nonce.

Copy link to headingAdd optional in-memory caching

On every request, the basic implementation performs an intra-platform fetch to retrieve the raw document from the CDN. When the same middleware instance remains warm, a small in-memory cache can remove that fetch.

In the following examples, selfFetch() represents the protected self-fetch from the implementation above, buffered into a RawDocument:

type RawDocument = {
body: string;
headers: Headers;
status: number;
};
const memory = new Map<string, RawDocument>();
function createCacheKey(request: Request): string | null {
const bypassHeaders = [
'authorization', 'cookie', 'range', 'if-match', 'if-none-match',
'if-modified-since', 'if-unmodified-since',
];
if (bypassHeaders.some((name) => request.headers.has(name))) return null;
const deployment = process.env.VERCEL_DEPLOYMENT_ID ?? 'local';
return `${deployment}:${request.url}`;
}
function canCache(request: Request, document: RawDocument): boolean {
const cacheControl = document.headers.get('cache-control') ?? '';
return createCacheKey(request) !== null &&
document.status === 200 &&
!document.headers.has('set-cookie') &&
!document.headers.has('vary') &&
!/private|no-store|no-cache/i.test(cacheControl);
}
function toResponse(document: RawDocument): Response {
return new Response(document.body, {
status: document.status,
headers: document.headers,
});
}
async function getRawDocument(request: Request) {
const key = createCacheKey(request);
const cached = key ? memory.get(key) : undefined;
if (cached) return cached;
const document = await selfFetch(request);
if (key && canCache(request, document)) memory.set(key, document);
return document;
}

Pass the buffered document through toResponse() before handing it to addFreshCspNonce(), so the transform from the main implementation works unchanged.

This cache is opportunistic. It is local to one warm instance, is not shared across regions and can disappear at any time. Bound both the number and total size of entries so the cache cannot grow indefinitely.

Only use it when the content is immutable for the lifetime of the deployment, or when its lifetime is otherwise bounded. Include the deployment ID in the key to prevent reuse across deployments.

The example conservatively bypasses all cookies and Set-Cookie responses. If Skew Protection uses the __vdpl cookie, you can explicitly allow only that cookie and include its value in the cache key. Continue to bypass application cookies and every other Set-Cookie response.

Copy link to headingUse Runtime Cache for revalidation

Vercel Runtime Cache fits better when multiple middleware instances in a region should share the raw document or content changes independently of a deployment.

The lookup follows the same pattern:

import { getCache } from '@vercel/functions';
type CachedRawDocument = Omit<RawDocument, 'headers'> & {
headers: [string, string][];
};
function serializeDocument(document: RawDocument): CachedRawDocument {
return {
...document,
headers: [...document.headers.entries()],
};
}
function deserializeDocument(document: CachedRawDocument): RawDocument {
return {
...document,
headers: new Headers(document.headers),
};
}
const cache = getCache({ namespace: 'csp-raw-html' });
async function getRuntimeCachedDocument(request: Request) {
const key = createCacheKey(request);
if (!key) return selfFetch(request);
const cached = (await cache.get(key)) as CachedRawDocument | null;
if (cached) return deserializeDocument(cached);
const document = await selfFetch(request);
if (canCache(request, document)) {
await cache.set(key, serializeDocument(document), {
ttl: 3600,
tags: ['site-html'],
});
}
return document;
}

Runtime Cache serializes values as JSON, so Web API objects such as Headers must be converted to a serializable representation. Runtime Cache is regional, and entries can be evicted at any time, so every lookup can miss. Entries are limited to 2 MB and items larger than this are not cached. Use a finite TTL and include a deployment or content version in the key because Runtime Cache entries can persist across deployments.

Apply the same tag to the raw CDN response and the Runtime Cache item. Because purging by cache tag purges entries with that tag across both the CDN cache and the Runtime Cache, a verified content webhook can invalidate the content everywhere with a single call:

import { invalidateByTag } from '@vercel/functions';
await invalidateByTag('site-html');

The Next.js revalidatePath() and revalidateTag() APIs do not invalidate the Runtime Cache. Use invalidateByTag() from @vercel/functions when the pattern in this guide caches documents in both layers.

Copy link to headingKeep optional caches safe

The optional in-memory and Runtime Cache layers are only safe when their keys and bypass rules reproduce the application's variation behavior. The cache key should include the URL and every value that changes the public HTML, such as selected query parameters, country, language, theme, or experiment values.

Do not key on every analytics cookie because this fragments the cache without changing the content. Bypass the optional cache for:

  • Authenticated or personalized responses
  • Application Set-Cookie responses
  • private or no-store responses
  • Responses with a Vary header
  • Range and conditional requests
  • Documents larger than the configured cache limit

These rules apply only to the optional in-memory or Runtime Cache layer. The raw CDN response must independently use equivalent cache and variation semantics.

Copy link to headingVerify the implementation

The internal self-fetch does not appear as a separate request in the browser's Network tab. During verification, copy its cache status to the final response:

headers.set(
'X-Raw-HTML-Cache',
raw.headers.get('X-Vercel-Cache') ?? 'unknown',
);

Create a preview deployment, then verify the flow in your browser:

  1. Open the Network tab and reload a page that contains scripts.
  2. Select the document request and open Headers. Confirm that the final response has Cache-Control: private, no-store and does not include CDN-Cache-Control or Vercel-CDN-Cache-Control.
  3. Confirm that X-Raw-HTML-Cache becomes HIT after the raw document has been cached. The first request after a deployment or purge may report MISS.
  4. Confirm that Content-Security-Policy contains script-src 'nonce-{value}' 'strict-dynamic'.
  5. Open the Response tab and search for nonce=. Every script should use the same nonce as the CSP header.
  6. Reload the page and repeat the check. X-Raw-HTML-Cache should remain HIT, while the nonce should be different from the previous document response.
  7. Open the Console and confirm that there are no unexpected CSP violations.

You can also check the active script nonces from the Console:

new Set([...document.scripts].map((script) => script.nonce));

The result should contain one non-empty value. Reload the page and run the command again to confirm that the value changes.

Copy link to headingPerformance considerations

This approach keeps expensive HTML rendering behind the CDN, but the final browser response is not a direct CDN hit. Every document request still invokes Routing Middleware, and the cost per request depends on the caching variant:

VariantWork per requestShared betweenSurvives
No extra cacheMiddleware + self-fetch through the CDN——
In-memory cacheMiddleware only, on a warm instanceOne instanceUntil the instance recycles
Runtime CacheMiddleware + one regional cache lookupAll instances in a regionAcross deployments, until TTL or tag expiry

Measure the uncached, in-memory and Runtime Cache variants from the regions where your users are located before choosing one.

The example buffers the complete document before adding the nonce, making that buffering time part of TTFB. It uses buffering because the complete response can be parsed, validated, cached and rejected before any bytes reach the browser.

Streaming is possible, but comes with additional limits. HTML tags can span chunks, so the transform needs an incremental HTML tokenizer. Once the response starts, middleware can no longer change its status or headers, reject a document with missing scripts or safely retry after a transformation error. You also cannot store a streaming response as a complete in-memory or Runtime Cache entry until the stream has finished.

Copy link to headingBest practices

  • Keep the bypass secret: Use a high-entropy environment secret, validate it exactly and handle redirects manually.
  • Strip credentials from the self-fetch: Delete authorization and application cookies so personalized markup doesn't enter the shared cache.
  • Never share transformed HTML: Return Cache-Control: private, no-store and remove CDN cache headers after inserting the nonce.
  • Limit the transformation: Process only GET HTML document requests. Make cache keys reproduce every variation in the raw response, and bypass authenticated or personalized content.
  • Validate before enforcement: Build one complete CSP, sanitize untrusted HTML and deploy with Content-Security-Policy-Report-Only before enforcing it.

Copy link to headingNext steps

Related documentation

More Vercel CDN guides