---
title: Content Security Policy
description: Run c15t in a TanStack Start app under a Content Security Policy,
  pass the router's per-request nonce to ConsentRoot, and allow the consent
  backend and vendor hosts.
group: frameworks
lastModified: "2026-10-10T16:01:45+01:00"
---
## What c15t adds to the page

A TanStack Start app with c15t adds these to the page, and your policy must
allow them:

|What|Directive|
|--|--|
|The `<style>` elements the banner, dialog and other stock components render with c15t's rules|`style-src` with the request nonce|
|The c15t stylesheet, only if you link it yourself with `styles: false`|`style-src 'self'`|
|The `<style id="c15t-theme">` element, if you render `ConsentTheme`|`style-src` with the request nonce|
|`<script>` elements the script loader creates for `scripts` entries, both `src` and inline|`script-src` with the request nonce, or each vendor's host|
|The inline `consentPrefetchHead` script, on prerendered pages that use it|`script-src` with its hash|
|Browser requests to `/api/c15t/*`, when you mount the consent route|`connect-src 'self'`|
|Browser requests to your backend's `/init` and `/subjects`|`connect-src` with the backend origin|
|Iframes inside `ConsentGate`, and the frames, images and requests your vendors load|`frame-src`, `img-src`, `connect-src` for each vendor|

Requests the Start server makes, such as the manifest fetch in
`createConsentStateHandler` and the proxied saves from
`createConsentRoute`, run outside the browser. The browser's policy does
not apply to them.

## Pass the request nonce to c15t

TanStack Router stamps a nonce on the scripts, styles and links it renders when
the router has `ssr.nonce`. Give c15t the same value, so the script loader,
the `<style>` elements the stock components render, and `ConsentTheme` use it
too. This takes three steps.

**1. Create the nonce and the policy in request middleware.** Generate a new
nonce for every request, send the policy header, and hand the nonce to the rest
of the request through the middleware context:

```ts title="src/csp-middleware.ts"
import { createMiddleware } from '@tanstack/react-start';
import { setResponseHeader } from '@tanstack/react-start/server';

export const cspMiddleware = createMiddleware().server(({ next }) => {
	const bytes = crypto.getRandomValues(new Uint8Array(16));
	const nonce = btoa(String.fromCharCode(...bytes));
	setResponseHeader(
		'Content-Security-Policy',
		[
			"default-src 'self'",
			`script-src 'nonce-${nonce}' 'strict-dynamic'`,
			`style-src 'self' 'nonce-${nonce}'`,
			"connect-src 'self' https://<your-backend-host>",
			"img-src 'self' data:",
		].join('; ')
	);
	return next({ context: { nonce } });
});
```

Add `cspMiddleware` to `requestMiddleware` in `src/start.ts`, creating the file
with `createStart` if your app has none. If the list also has
`consentRequestMiddleware()`, put `cspMiddleware` first. Replace
`https://<your-backend-host>` with the origin of the backend URL from your Inth
project or self-hosted backend, and add your vendors' hosts.

**2. Give the router the nonce.** `getRouter` runs on the server after the
global request middleware, so it can read the nonce from the Start context. In
the browser, TanStack Router reads it back from the
`<meta property="csp-nonce">` tag that `HeadContent` renders:

```tsx title="src/router.tsx"
import { createRouter } from '@tanstack/react-router';
import { getGlobalStartContext } from '@tanstack/react-start';

import { routeTree } from './routeTree.gen';

export const getRouter = function getRouter() {
	return createRouter({
		routeTree,
		ssr: { nonce: getGlobalStartContext()?.nonce },
	});
};
```

**3. Pass the router's nonce to ConsentRoot.** In the root component from the
[quickstart](/docs/frameworks/tanstack-start/quickstart#resolve-consent-in-the-root-route),
read the nonce with `useRouter` from `@tanstack/react-router` and pass it as
`options.nonce`:

```tsx title="src/routes/__root.tsx (partial)"
const nonce = useRouter().options.ssr?.nonce;

<ConsentRoot
  state={consent}
  options={{ nonce }}
  scripts={scripts}
>
```

If you render `ConsentTheme`, pass it the same value:
`<ConsentTheme theme={theme} nonce={nonce} />`.

`ConsentRoot` reads `options.nonce` when it mounts, and the script loader
stamps it on every `<script>` it creates. A `nonce` on a single `scripts` entry
takes precedence for that element. Each full page load gets the nonce of its
own request. Do not serve nonce pages from a shared cache, because every
visitor would get the same nonce.

## What the nonce does not cover

* **Scripts that a vendor script loads itself**, such as the tags a tag manager
  injects. `'strict-dynamic'` in the policy above allows scripts that a nonced
  script adds. Without it, list each vendor's hosts in `script-src`.
* **Inline `style` attributes.** In the browser, c15t components set their
  inline styles through React, and a policy does not restrict styles set that
  way. If the console shows `Refused to apply inline style` for a
  server-rendered c15t element, add `style-src-attr 'unsafe-inline'`. A nonce
  cannot allow style attributes.
* **Iframes, images and requests** your vendors make. Add their hosts to
  `frame-src`, `img-src` and `connect-src`. A YouTube player inside
  `ConsentGate` needs `https://www.youtube-nocookie.com` in `frame-src`, for
  example.
* **The DevTools panel.** It adds a `<style>` element without a nonce, so under
  this policy it renders unstyled. Load it only in development.

## Allow the consent backend

Which consent requests the browser makes depends on your
[rendering setup](/docs/frameworks/tanstack-start/rendering):

|Setup|Browser requests|Policy|
|--|--|--|
|Quickstart|`/init` and `/subjects` on your backend URL|`connect-src` with the backend origin|
|Consent route with `createConsentStateHandler({ routePrefix: '/api/c15t' })`|`/api/c15t/init` on your origin, `/subjects` on your backend URL|`connect-src 'self'` plus the backend origin|
|Consent route with `proxy: true` on the route and on `createConsentStateHandler({ proxy: true, routePrefix: '/api/c15t' })`|Every consent request on your origin|`connect-src 'self'`|

Under an IAB TCF policy the browser also loads the Global Vendor List. With the
consent route and `routePrefix`, it comes through `/api/c15t/init`, which
`'self'` covers. Otherwise the browser fetches it from the URL the policy
provides, so allow that host in `connect-src`. See
[IAB TCF](/docs/frameworks/tanstack-start/iab).

## Prerendered pages and SPA mode

A prerendered page is the same HTML for every visitor, so it cannot carry a
per-request nonce. Use host allowlists and hashes instead:

* List `'self'` and each vendor's script host in `script-src`.
* `consentPrefetchHead` adds an inline script whose content depends only on
  its options. Load the page with the policy enforced, copy the `sha256-...`
  value from the `Refused to execute inline script` console error, and add it
  to `script-src`. Recompute it when you change the options.
* `ConsentTheme` output depends only on the theme, so a hash works for it in
  `style-src` the same way. Recompute it when you change the theme.

TanStack Start adds its own inline scripts to prerendered pages. Its
documentation covers how to allow those.

## Verify

Build the app, start the production server, and load a page with the policy
enforced, not in report-only mode. Test under a policy that asks for a choice,
such as an EU opt-in policy.

1. The banner renders with its theme colors, and the console shows no
   `Refused to` error that mentions a c15t element, your backend or a vendor.
2. In the Elements panel, `<style id="c15t-theme">` and the
   `<meta property="csp-nonce">` tag carry the same nonce as the response's
   `Content-Security-Policy` header. Browsers hide the `nonce` attribute from
   `getAttribute`, so read the element's `nonce` property in the console.
3. Before a choice, DevTools Network shows no vendor requests.
4. Click **Accept All**. The script loader's `<script>` elements, with ids starting
   `c15t-`, carry the nonce, and the vendor requests appear.
5. The save to `/subjects` succeeds. A `connect-src` violation here means the
   backend origin is missing.
6. Reload. The header has a new nonce, and the elements carry the new value.
