---
title: How to add per-request CSP nonces to CDN-cached HTML on Vercel
description: Use Routing Middleware and a self-fetch to add a fresh CSP nonce to cached HTML without rendering the page again on every request.
url: /kb/guide/csp-nonces-with-cdn-cache
canonical_url: "https://vercel.com/kb/guide/csp-nonces-with-cdn-cache"
published: 2026-09-04
last_updated: 2026-09-04
authors: Leo Reuter
related:
  - /docs/routing-middleware
  - /docs/skew-protection
  - /docs/caching/runtime-cache
  - /docs/caching/cdn-cache
  - /docs/caching/cdn-cache/purge
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

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](https://vercel.com/docs/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.

## Overview

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
  

## When 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                       |

## How 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:

| 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 |

## Implement 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:

```typescript
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](https://vercel.com/docs/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](https://vercel.com/d?to=%2F%5Bteam%5D%2F%5Bproject%5D%2Fsettings%2Fenvironment-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:

```typescript
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.

## Build 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.

## Add 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`:

```typescript
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.

## Use Runtime Cache for revalidation

[Vercel Runtime Cache](https://vercel.com/docs/caching/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:

```typescript
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:

```typescript
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.

## Keep 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.

## Verify 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:

```typescript
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:

```typescript
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.

## Performance 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.

## Best 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.
  

## Next steps

- Learn how [Vercel Routing Middleware](https://vercel.com/docs/routing-middleware) runs before the cache
  
- Configure [CDN caching](https://vercel.com/docs/caching/cdn-cache) and response cache headers
  
- Use the [Runtime Cache API](https://vercel.com/docs/caching/runtime-cache) for regional cache entries and tag expiration
  
- Read about [purging the CDN cache](https://vercel.com/docs/caching/cdn-cache/purge) with tags
  
- Review MDN's [strict CSP](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP#strict_csp) guidance