+1 (415) 779-8456

Security Headers and a Real CSP for a Gatsby Site

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:

HostMechanism
Netlifystatic/_headers (copied into public/) or netlify.toml
Cloudflare Pagesstatic/_headers, same syntax
Vercelheaders array in vercel.json
S3 + CloudFrontCloudFront response headers policy, or a viewer-response function
Nginx / Apacheadd_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-Security is 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 leave preload off until you are certain.
  • X-Frame-Options: DENY breaks legitimate embedding. If a client embeds your booking widget or a partner iframes a page, use Content-Security-Policy: frame-ancestors https://partner.example.com instead — it is the modern replacement and it accepts a list.
  • X-XSS-Protection is 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

  1. Add the four uncontroversial headers to static/_headers (or your host's equivalent).
  2. Deploy them and confirm with curl -I that they arrive.
  3. Add the onPostBuild hook and ship the generated policy as report-only.
  4. Enumerate every third-party origin the site genuinely uses; delete the ones nobody can justify.
  5. 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.