Hydrogen already ships a content security policy. createContentSecurityPolicy() in entry.server.tsx builds a Content-Security-Policy header with a per-request nonce, and the skeleton template sets it on every response, as Shopify’s content security policy guide walks through. You rarely write a policy from scratch; you extend this one. To do that without a console full of violations you need three things: what the default header contains, how your directives merge with it (they append, with one exception), and where third-party scripts, fonts and images have to be declared.
Everything below is written against @shopify/[email protected], the React Router line. As of October 2026 the npm latest tag (2026.10.0) is the new framework-agnostic SDK and does not export createContentSecurityPolicy, so pin 2026.4 if you are following along in a fresh project.
What does the Hydrogen content security policy allow by default?
The createContentSecurityPolicy reference says only that the default policy “includes exclusions for cdn.shopify.com and a script nonce”. The package source is more specific. Calling createContentSecurityPolicy({shop: {checkoutDomain, storeDomain}}) with no other directives produces this header in a production build (nonce shortened):
base-uri 'self';
default-src 'self' https://cdn.shopify.com https://shopify.com 'nonce-2af13d...';
frame-ancestors 'none';
style-src 'self' 'unsafe-inline' https://cdn.shopify.com;
connect-src 'self' https://cdn.shopify.com/ https://monorail-edge.shopifysvc.com https://checkout.example.com https://example.myshopify.com
Five directives. The gaps between them decide most of what you will have to add.
There is no script-src. Scripts fall through to default-src, which is where the nonce lives, so an inline <script> or a cross-origin script runs only if it carries nonce="..." or comes from your origin, cdn.shopify.com or shopify.com. The shopify.com entry exists for the Customer Account API login flow.
There is no img-src or font-src either. Both fall through to default-src too, which means a product image served from anywhere other than your origin or Shopify’s CDN is blocked, and so is a data: URI placeholder. A Google Font fails twice: the stylesheet needs style-src and the woff2 files need font-src.
style-src carries 'unsafe-inline', which is what lets style="" attributes and inline <style> blocks through. Hydrogen puts it there itself, and the merge rules in the next section mean you cannot take it out through the API.
connect-src lists Shopify’s CDN, the Monorail analytics endpoint and whichever checkoutDomain and storeDomain you pass. Hydrogen’s customer privacy and consent code is configured with the same two domains and makes browser requests to them, so leave shop out and those requests fail. The skeleton reads both from context.env.PUBLIC_CHECKOUT_DOMAIN and context.env.PUBLIC_STORE_DOMAIN.
The development build adds http://localhost:* to default-src and style-src, plus ws://localhost:*, ws://127.0.0.1:* and ws://*.tryhydrogen.dev:* to connect-src for Vite’s HMR socket. None of that appears in the production bundle, so a policy that is quiet in shopify hydrogen dev can still break on Oxygen.
Do custom directives replace the defaults or extend them?
They extend them, and the order of the merge matters.
For a directive Hydrogen already defines (baseUri, defaultSrc, frameAncestors, styleSrc, connectSrc) your values are placed first and the defaults are appended after them. Pass styleSrc: ['https://fonts.googleapis.com'] and the header reads style-src https://fonts.googleapis.com 'self' 'unsafe-inline' https://cdn.shopify.com. You cannot drop 'unsafe-inline' or https://shopify.com by omitting them, because they are never yours to omit.
The exception is a default of 'none'. frameAncestors is the only one, and the merge code treats it as replaceable: pass frameAncestors: ['https://admin.example.com'] and 'none' disappears. That is correct behaviour, since 'none' combined with anything else is meaningless.
For a directive Hydrogen does not define (scriptSrc, imgSrc, fontSrc, frameSrc, formAction, workerSrc and the rest of the 24 keys on CreateContentSecurityPolicy) there is nothing to merge with. What you pass is the whole directive. Set imgSrc and forget 'self', and your own /public images stop loading.
scriptSrc gets one extra treatment. If you set it, Hydrogen strips any 'nonce-...' you included and appends the current request’s nonce, so React’s bootstrap scripts keep working. default-src keeps its nonce as well. What you must now supply yourself is everything that used to arrive via default-src: 'self' and https://cdn.shopify.com at minimum, since a declared script-src stops the browser consulting default-src for scripts at all. Shopify’s own Google Tag Manager recipe does exactly this.
Here is a complete entry.server.tsx that adds GTM, Google Fonts and an external image host:
import {ServerRouter} from 'react-router';
import {isbot} from 'isbot';
import {renderToReadableStream} from 'react-dom/server';
import {
createContentSecurityPolicy,
type HydrogenRouterContextProvider,
} from '@shopify/hydrogen';
import type {EntryContext} from 'react-router';
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
reactRouterContext: EntryContext,
context: HydrogenRouterContextProvider,
) {
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
shop: {
checkoutDomain: context.env.PUBLIC_CHECKOUT_DOMAIN,
storeDomain: context.env.PUBLIC_STORE_DOMAIN,
},
// Whole directive: Hydrogen has no default script-src.
scriptSrc: [
"'self'",
'https://cdn.shopify.com',
'https://*.googletagmanager.com',
],
// Appended to Hydrogen's style-src and connect-src defaults.
styleSrc: ['https://fonts.googleapis.com'],
connectSrc: [
'https://*.google-analytics.com',
'https://*.analytics.google.com',
'https://*.googletagmanager.com',
],
// Whole directives again: include 'self' and the Shopify CDN.
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: [
"'self'",
'data:',
'https://cdn.shopify.com',
'https://images.example-cdn.com',
'https://*.google-analytics.com',
],
});
const body = await renderToReadableStream(
<NonceProvider>
<ServerRouter
context={reactRouterContext}
url={request.url}
nonce={nonce}
/>
</NonceProvider>,
{
nonce,
signal: request.signal,
onError(error) {
console.error(error);
responseStatusCode = 500;
},
},
);
if (isbot(request.headers.get('user-agent'))) {
await body.allReady;
}
responseHeaders.set('Content-Type', 'text/html');
responseHeaders.set('Content-Security-Policy', header);
return new Response(body, {
headers: responseHeaders,
status: responseStatusCode,
});
}
Values are strings or arrays of strings, and camel case becomes the dashed directive name. upgradeInsecureRequests: true and blockAllMixedContent: true are the two booleans, emitted as bare directives.
How does the nonce reach every script tag?
createContentSecurityPolicy generates 16 random bytes with crypto.getRandomValues and hex-encodes them, once per request. It hands that string back three ways and all three are needed.
The nonce value goes to renderToReadableStream as an option, which is how React stamps it on the inline scripts it emits for streaming and hydration. It also goes to <ServerRouter nonce={nonce}>.
NonceProvider wraps the tree and puts the value in a React context. useNonce() reads it. In root.tsx you pass that to <Scripts nonce={nonce} /> and <ScrollRestoration nonce={nonce} />, because both components render inline scripts. Miss either and the page loads with a console full of Refused to execute inline script errors and no hydration. The current skeleton does this once in the Layout export, which wraps both App and ErrorBoundary. If your root still renders a separate tree for ErrorBoundary, pass the nonce there too.
The header string goes on the response. Header and markup have to come from the same request, because the nonce is what they share. Cache the rendered HTML at a CDN and you get one of two failures: a stored nonce in the markup that no longer matches a freshly generated header, so no script runs, or the same nonce served to every visitor, which hands an attacker the value a nonce exists to hide.
For third-party scripts, Hydrogen’s Script component calls useNonce() internally and sets the attribute for you:
import {Script, useNonce} from '@shopify/hydrogen';
import {Scripts, ScrollRestoration} from 'react-router';
export default function App() {
const nonce = useNonce();
return (
<html lang="en">
<head>
<Script src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX" />
</head>
<body>
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
</html>
);
}
One limit. <Script waitForHydration src="..." /> defers loading until after hydration by injecting the tag from the browser, and useNonce() returns undefined on the client by design. Those scripts cannot carry a nonce, so their origin has to be listed in scriptSrc instead. The same applies to any tag injected by a tag manager at runtime: GTM’s own container script can be nonced, but the tags it loads need their hosts allowed by directive.
How do you roll out a stricter policy without breaking checkout?
Set the header as Content-Security-Policy-Report-Only first. The browser reports violations to the console and to any reportTo or reportUri destination you set, but blocks nothing. Both keys are accepted by createContentSecurityPolicy, and the header name is yours to choose when you call responseHeaders.set. Run it in report-only for a week of real traffic, read what the pixels, review widgets and A/B tools actually fetch, then switch the header name back.
Two things that look like CSP problems are not. Checkout itself runs on Shopify’s checkout domain, outside your Hydrogen app, so your header never applies there; the checkoutDomain entry in connect-src is for the consent and analytics requests your storefront makes from the browser, not for checkout pages. And a frame-ancestors 'none' violation when someone embeds your store in an iframe is the default doing its job.
For the general mechanics of nonces versus hashes, including what 'strict-dynamic' does to an allowlist, see our write-up on choosing between CSP nonces and hashes. For what Hydrogen’s own analytics code does with the events those scripts care about, see our post on Hydrogen analytics events.
My recommendation: keep Hydrogen’s defaults, pass shop, and add only the directives a report-only run proves you need. Set scriptSrc explicitly the first time you add any third-party script, since that is the moment default-src stops governing scripts and the moment to list 'self' and the Shopify CDN deliberately. Do not build the header yourself to get 'unsafe-inline' out of style-src unless you have audited every inline style the storefront emits. The API will not remove it for you, and a hand-built header is a fork of the policy that you re-check on every Hydrogen upgrade.
Whoooop builds and maintains Hydrogen storefronts, and a locked-down CSP is part of every one we hand over. If yours is firing violations you cannot trace, or you are adding a tag manager and want the directives right first time, our Shopify development team can help.