Skip to main content

TanStack Start Advanced

Content Security Policy

What c15t adds to the page

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

WhatDirective
The <style> elements the banner, dialog and other stock components render with c15t's rulesstyle-src with the request nonce
The c15t stylesheet, only if you link it yourself with styles: falsestyle-src 'self'
The <style id="c15t-theme"> element, if you render ConsentThemestyle-src with the request nonce
<script> elements the script loader creates for scripts entries, both src and inlinescript-src with the request nonce, or each vendor's host
The inline consentPrefetchHead script, on prerendered pages that use itscript-src with its hash
Browser requests to /api/c15t/*, when you mount the consent routeconnect-src 'self'
Browser requests to your backend's /init and /subjectsconnect-src with the backend origin
Iframes inside ConsentGate, and the frames, images and requests your vendors loadframe-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:

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:

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, read the nonce with useRouter from @tanstack/react-router and pass it as options.nonce:

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.

Which consent requests the browser makes depends on your rendering setup:

SetupBrowser requestsPolicy
Quickstart/init and /subjects on your backend URLconnect-src with the backend origin
Consent route with createConsentStateHandler({ routePrefix: '/api/c15t' })/api/c15t/init on your origin, /subjects on your backend URLconnect-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 originconnect-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.

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.