Skip to main content

Security headers

Every page on the site is served with security headers that tell browsers what the page is allowed to do. Most are fixed values in netlify.toml. The Content Security Policy is generated at build time by plugins/csp-headers-plugin.mjs, because it has to list the site's inline scripts by hash.

Quick reference

  • netlify.toml sets HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and Permissions-Policy on every page.
  • After each build, plugins/csp-headers-plugin.mjs hashes every inline script in the built HTML and writes the Content Security Policy into build/_headers, which Netlify reads at deploy time.
  • The full policy is enforced: browsers block anything that isn't on the allowlist.
  • When the site starts loading something from a new domain, add that domain to SOURCES in the plugin, or the browser will block it. To test a change without risk, set ENFORCE_FULL_POLICY to false first (see Testing a change to the policy).
  • One console error on /api-reference is expected: Scalar checks whether eval is allowed, and it isn't. See Expected console message.

Headers in netlify.toml​

netlify.toml
[[headers]]
for = "/*"
[headers.values]
Strict-Transport-Security = "max-age=31536000; includeSubDomains"
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Permissions-Policy = "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
HeaderEffect
Strict-Transport-SecurityBrowsers only use HTTPS for www.marlasowards.com (and any subdomains of www, of which there are none) for a year.
X-Frame-Options: DENYNo other site can show these pages in a frame, which prevents clickjacking. The CSP's frame-ancestors 'none' does the same for newer browsers.
X-Content-Type-Options: nosniffBrowsers use each file's declared type instead of guessing, so a text file can't be run as a script.
Referrer-PolicyLinks to other sites only send https://www.marlasowards.com as the referrer, not the full page URL.
Permissions-PolicyThe pages can't use the camera, microphone, location, payments, or USB devices.

The Content Security Policy​

A Content Security Policy (CSP) lists where a page may load scripts, styles, fonts, images, and data from. If an attacker ever got a script into a page, through a compromised dependency or a content mistake, the browser would refuse to run it unless it matched the policy.

What the plugin writes​

plugins/csp-headers-plugin.mjs is registered in docusaurus.config.js and runs after docusaurus build. It:

  1. Reads every .html file in build/.
  2. Finds each inline <script> that runs as JavaScript, and skips data blocks such as JSON-LD (type="application/ld+json").
  3. Hashes each distinct script with SHA-256.
  4. Writes build/_headers with the policy. The build log shows a line such as [csp-headers-plugin] Wrote _headers with 3 inline script hash(es).

The site currently has three inline scripts: the color-mode script and the Google Analytics setup on every page, and a small banner script on the home page. Their hashes change whenever Docusaurus or the analytics snippet changes, which is why they're computed on every build instead of written by hand.

The policy​

The policy is sent as Content-Security-Policy, so browsers enforce it:

DirectiveAllows
default-srcThis site only
script-srcThis site, Google Tag Manager, Scalar's exact file on jsDelivr (not the whole CDN), and the inline scripts by hash
style-srcThis site, inline styles (React and Scalar set them, and they can't be hashed), and Google Fonts' stylesheets
font-srcThis site, Google Fonts, and data: URLs
img-srcThis site, data: URLs, and any HTTPS image
connect-srcThis site, api.marlasowards.com, Scalar's source map, Google Analytics, and the Iconify icon API (@iconify/react fetches icons that aren't bundled)

It also includes these, which can't break the site, since it's never framed, has no plugins (<object>), uses no <base> tag, and has no forms that post elsewhere:

frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests

'unsafe-eval' is deliberately not allowed, so no script can run a string as code.

How it was turned on​

The full policy started as report-only, so browsers would log violations without blocking anything. A check of the home page, a blog post, a docs page, the portfolio, and /api-reference (with a Test Request) in October 2026 found two messages:

  • 'upgrade-insecure-requests' is ignored when delivered in a report-only policy. Browsers don't apply that directive in report-only mode. The plugin now leaves the enforced directives out of a report-only policy.
  • The eval check described below, on /api-reference only.

Neither needed an allowlist change, so ENFORCE_FULL_POLICY was set to true.

Scalar on jsDelivr​

jsDelivr serves every package on npm, so allowing https://cdn.jsdelivr.net would let an injected script tag load any of them. The policy allows Scalar's exact file instead, read from src/data/scalar.mjs, the same place the reference page gets it. It also allows a connection to that file's source map, which browsers fetch only when developer tools are open; without it, opening developer tools on /api-reference showed a Refused to connect to ...standalone.js.map error.

Expected console message​

On /api-reference, the browser console shows:

Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src ..."

Chrome's Issues panel reports the same thing as Content Security Policy of your site blocks the use of 'eval' in JavaScript, with the source location util.js:157. That's zod/v4/core/util.js in Scalar's bundle, which the panel can show because the source map is allowed.

It's harmless. Scalar bundles the Zod validation library, which checks once at startup whether eval is allowed (try { Function('') } catch {}), so it can compile its validators for speed when it is. Under this policy the check fails, Zod uses its normal validation path, and the page works the same. Don't add 'unsafe-eval' to silence it: blocking eval is part of what the policy is for.

Any other Refused to... message is a real problem: something the site needs is blocked.

Testing a change to the policy​

  1. In plugins/csp-headers-plugin.mjs, add the new source to SOURCES and set ENFORCE_FULL_POLICY = false.
  2. Deploy, open the site with the developer console open, and visit the affected pages. Violations show up as [Report Only] messages, and nothing is blocked.
  3. When the only message is the expected one above, set ENFORCE_FULL_POLICY = true again, deploy, and check the same pages once more.

Third-party scripts​

Two third-party scripts run on the site, and the policy allows both:

  • Google Analytics (googletagmanager.com), on every page.
  • Scalar (cdn.jsdelivr.net), only on /api-reference. It's pinned to one version and loaded with a subresource integrity hash, so the browser refuses it if the CDN's copy changes. See The API reference page.