Static sites get a reputation for being secure because there is no server to compromise. That is half true: there is no database to inject into and no PHP to pop. What remains is the browser. A Gatsby site ships a large JavaScript bundle, usually pulls in a few third-party scripts, and renders content from a CMS. Every one of those is a route to running someone else's code on your origin — and a compromised npm package or a poisoned CMS field is exactly the kind of thing a Content Security Policy is designed to blunt.
Most Gatsby sites we audit ship with no security headers at all, or with a Content-Security-Policy copied from a blog post that is either wide open (script-src 'self' 'unsafe-inline' *) or so strict the site breaks in production and someone quietly deletes it. This tutorial covers a policy you can actually deploy on a Gatsby 5 site, how to work around Gatsby's inline scripts without unsafe-inline, and how to roll it out in report-only mode so you find the breakage before your users do.
Where headers come from on a static site
Gatsby emits files, so the headers are your host's job, not Gatsby's. Three places to configure them, pick one and only one:
| Host | Mechanism |
|---|---|
| Netlify | static/_headers (copied into public/) or netlify.toml |
| Cloudflare Pages | static/_headers, same syntax |
| Vercel | headers array in vercel.json |
| S3 + CloudFront | CloudFront response headers policy, or a viewer-response function |
| Nginx / Apache | add_header / Header set in the server config |
Note the Gatsby detail: files placed in static/ are copied verbatim into public/ during build, so static/_headers is the idiomatic way to ship a headers file from the repo. Do not also set headers in netlify.toml — when both exist the precedence rules will surprise you, and you will spend an afternoon debugging a header you already removed.
Start with the easy headers
These four are uncontroversial, break nothing on a normal marketing or content site, and should go in today:
/*
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: DENY
Strict-Transport-Security: max-age=31536000; includeSubDomains
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
A few notes before you paste it:
Strict-Transport-Securityis a commitment. Once a browser has seen it, it will refuse plain HTTP to your domain for a year. Ship it only when every subdomain you serve is HTTPS, and leavepreloadoff until you are certain.X-Frame-Options: DENYbreaks legitimate embedding. If a client embeds your booking widget or a partner iframes a page, useContent-Security-Policy: frame-ancestors https://partner.example.cominstead — it is the modern replacement and it accepts a list.X-XSS-Protectionis obsolete. Modern browsers ignore it; some older ones behaved worse with it on. Leave it out.
Deploy those, confirm nothing broke, and only then start on CSP.
The hard part: Gatsby's inline scripts
Gatsby's HTML output contains inline <script> tags — the webpack runtime chunk mapping and the page data preload hints among them. A policy of script-src 'self' alone will block them and you get a blank page. There are three ways out.
Option 1 — hashes. Compute a SHA-256 for each inline script and list them. Correct, but the webpack runtime hash changes on most builds, so the policy must be generated at build time or you ship a broken site the next time a dependency bumps.
Option 2 — a nonce. The right answer for server-rendered apps, and mostly the wrong one here: a nonce must be unique per response, and a static HTML file on a CDN is one response served a million times. Only viable if you have an edge function rewriting HTML per request, which is a lot of machinery for a brochure site.
Option 3 — generate hashes at build time. This is what we do on client sites. Gatsby has a onPostBuild hook that runs after public/ is written, so you can walk the HTML, hash every inline script, and write the policy into public/_headers yourself.
// gatsby-node.js
const fs = require('node:fs/promises');
const path = require('node:path');
const crypto = require('node:crypto');
const SCRIPT_RE = /<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g;
async function walk(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = await Promise.all(
entries.map((e) => {
const full = path.join(dir, e.name);
return e.isDirectory() ? walk(full) : full.endsWith('.html') ? [full] : [];
}),
);
return files.flat();
}
exports.onPostBuild = async ({ reporter }) => {
const publicDir = path.join(process.cwd(), 'public');
const hashes = new Set();
for (const file of await walk(publicDir)) {
const html = await fs.readFile(file, 'utf8');
for (const [, body] of html.matchAll(SCRIPT_RE)) {
if (!body.trim()) continue;
const digest = crypto.createHash('sha256').update(body, 'utf8').digest('base64');
hashes.add(`'sha256-${digest}'`);
}
}
const scriptSrc = ["'self'", ...hashes].join(' ');
const policy = [
"default-src 'self'",
`script-src ${scriptSrc}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
'upgrade-insecure-requests',
].join('; ');
const headers = `/*\n Content-Security-Policy: ${policy}\n X-Content-Type-Options: nosniff\n Referrer-Policy: strict-origin-when-cross-origin\n`;
await fs.writeFile(path.join(publicDir, '_headers'), headers, 'utf8');
reporter.info(`CSP written with ${hashes.size} inline script hashes`);
};
Two caveats that bite people. The hash must cover the script body byte-for-byte, including whitespace, so do not run an HTML minifier after this hook. And if any inline script contains build-specific data — a timestamp, a per-page JSON payload — the hash set grows with every page and the header becomes enormous; CDNs typically cap response headers around 8–16 KB. If you hit that, move the payload out to an external file and re-run.
If your Gatsby build produces a small, stable set of inline scripts, this generated policy is stable in practice, and the reporter.info line tells you immediately when the count changes.
Style, images, and the third parties
style-src 'unsafe-inline' is in the policy above deliberately. Gatsby's styling ecosystem — gatsby-plugin-image's placeholder styles, emotion, styled-components — injects inline styles at runtime, and inline style is a far weaker attack primitive than inline script. Removing it is a project of its own; take the script-side win first.
For everything else, be explicit rather than generous:
img-src 'self' data: https://images.ctfassets.net https://res.cloudinary.com;
connect-src 'self' https://plausible.io https://api.example.com;
script-src ... https://plausible.io;
frame-src https://www.youtube-nocookie.com;
Enumerating the CMS asset domain and your analytics endpoint takes ten minutes and is the difference between a policy that stops an exfiltration attempt and one that does not. A wildcard https: on connect-src in particular means a malicious dependency can post your form data anywhere it likes.
Third-party tag managers are where CSP goes to die. Google Tag Manager effectively requires unsafe-inline or unsafe-eval for many container configurations, at which point the script policy is decorative. On a static site you rarely need it: a privacy-friendly analytics script loaded from one named origin, plus a connect-src entry for its collector, covers most of what marketing actually uses, and it survives a real CSP.
Roll out in report-only mode first
Never ship a new CSP as an enforcing header. Ship it as Content-Security-Policy-Report-Only alongside your existing headers — browsers will evaluate it, log violations to the console, and change nothing about page behaviour.
/*
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' 'sha256-...'; report-uri https://example.report-uri.com/r/d/csp/reportOnly
Leave it in place for a week of real traffic. You are looking for the things local testing never surfaces: a client's marketing team pasting a chat widget in, an old embed on a page nobody visits, a browser extension (those you ignore — extension violations are noisy and not your problem). When the report stream is quiet except for extensions, rename the header to Content-Security-Policy and redeploy.
Verify it in CI
A CSP that regresses silently is worth very little. Two checks, both cheap:
BASE=https://www.example.com
# 1. The headers are actually on the response
curl -sSI "$BASE/" | grep -iE 'content-security-policy|x-content-type|referrer-policy|strict-transport'
# 2. The policy has not quietly gained an escape hatch
curl -sSI "$BASE/" | grep -i 'content-security-policy' \
| grep -qE "unsafe-eval|script-src[^;]*\*" \
&& { echo 'CSP weakened - failing build'; exit 1; } || echo 'CSP ok'
Run those as a post-deploy step in the same workflow that builds the site. For a broader grade, https://securityheaders.com and Mozilla Observatory both give a one-page report you can hand to a client.
What this protects against, honestly
A CSP does not stop a malicious dependency from running at build time — that happens on your CI machine, long before any header exists, and it is a separate problem with separate controls. What it does is contain a compromised script once it reaches the browser: it cannot load a payload from an attacker's domain, cannot post scraped form fields to one, and cannot inject a <base> tag to hijack your relative URLs. On a static site where the client-side bundle is the entire attack surface, that containment is most of the security work available to you.
Checklist for this week
- Add the four uncontroversial headers to
static/_headers(or your host's equivalent). - Deploy them and confirm with
curl -Ithat they arrive. - Add the
onPostBuildhook and ship the generated policy as report-only. - Enumerate every third-party origin the site genuinely uses; delete the ones nobody can justify.
- After a week of clean reports, switch to enforcing and add the CI check.
If you would like a Gatsby site reviewed against this — or the build-time hash generation wired into an existing pipeline without breaking a deploy — get in touch.