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 nonces | CDN-cached HTML | Recommended approach |
|---|---|---|
| Yes | Yes | The self-fetch pattern in this guide |
| Yes | No | Generate the nonce during rendering |
| No | Yes | Hash-based CSP |
Copy link to headingHow it works
The request passes through Routing Middleware twice:
- The browser request enters middleware.
- Middleware fetches the same URL with a secret internal header.
- The internal request re-enters middleware. After validating the secret, middleware lets it continue.
- Vercel returns the raw document from the CDN, rendering it only on a cache miss.
- The outer middleware request buffers the document, generates a nonce and adds it to every script.
- The transformed response is returned with a matching CSP header.
The two responses have different caching rules:
| Response | Cache policy | Contains a nonce |
|---|---|---|
| Raw HTML (internal self-fetch) | Public, stored by the CDN | No |
| Transformed HTML (browser response) | private, no-store, never shared-cached | Yes, 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:
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:
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'inscript-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:
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:
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:
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-Cookieresponses privateorno-storeresponses- Responses with a
Varyheader - 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:
Create a preview deployment, then verify the flow in your browser:
- Open the Network tab and reload a page that contains scripts.
- Select the document request and open Headers. Confirm that the final response has
Cache-Control: private, no-storeand does not includeCDN-Cache-ControlorVercel-CDN-Cache-Control. - Confirm that
X-Raw-HTML-CachebecomesHITafter the raw document has been cached. The first request after a deployment or purge may reportMISS. - Confirm that
Content-Security-Policycontainsscript-src 'nonce-{value}' 'strict-dynamic'. - Open the Response tab and search for
nonce=. Every script should use the same nonce as the CSP header. - Reload the page and repeat the check.
X-Raw-HTML-Cacheshould remainHIT, while the nonce should be different from the previous document response. - Open the Console and confirm that there are no unexpected CSP violations.
You can also check the active script nonces from the Console:
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:
| Variant | Work per request | Shared between | Survives |
|---|---|---|---|
| No extra cache | Middleware + self-fetch through the CDN | — | — |
| In-memory cache | Middleware only, on a warm instance | One instance | Until the instance recycles |
| Runtime Cache | Middleware + one regional cache lookup | All instances in a region | Across 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
authorizationand application cookies so personalized markup doesn't enter the shared cache. - Never share transformed HTML: Return
Cache-Control: private, no-storeand remove CDN cache headers after inserting the nonce. - Limit the transformation: Process only
GETHTML 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-Onlybefore enforcing it.
Copy link to headingNext steps
- Learn how Vercel Routing Middleware runs before the cache
- Configure CDN caching and response cache headers
- Use the Runtime Cache API for regional cache entries and tag expiration
- Read about purging the CDN cache with tags
- Review MDN's strict CSP guidance