Security Headers and Hardening for Static Sites

"It's just static files" is the most common reason static sites ship without security headers. There is no database to inject into and no server-side code to exploit, so the threat model feels empty. It is not. Browsers run whatever JavaScript a page loads, and static sites load plenty: analytics, embeds, consent managers, chat widgets and their own bundles, all built from hundreds of npm packages by a CI pipeline holding a token that can overwrite every page. Security for a static site is about limiting what that JavaScript can do, making sure the right JavaScript is served over the right connection, and keeping the pipeline that publishes it from being turned against you.

This topic covers both halves. The edge half is response headers — Content-Security-Policy, Strict-Transport-Security, Subresource Integrity, framing and referrer controls — set once at the host and applied to every page. The pipeline half is dependency auditing and deploy credentials. It sits inside Production-Ready Deployment & CI/CD Workflows and builds on the header mechanics from Cloudflare Pages Edge Caching Setup.

Threats to a static site and the control for each A pipeline from source to reader with threats and controls at each stage. At the dependency stage, a malicious npm package is countered by lockfiles, audits and provenance. At the CI stage, a leaked deploy token is countered by short-lived OIDC credentials. At the network stage, a downgrade to HTTP is countered by HSTS. In the browser, injected or compromised scripts are countered by CSP and SRI, and clickjacking by frame-ancestors. Four places an attacker can get in, four controls Dependencies npm, themes, plugins CI pipeline build + deploy token Network reader to edge Browser scripts, frames malicious package runs at build time leaked token publishes anything HTTP downgrade content tampered injected script clickjacking lockfile, audit, provenance OIDC, scoped, short-lived HSTS + preload CSP, SRI, frame-ancestors Headers protect the right half; pipeline hygiene protects the left half
Static sites remove the server from the threat model, not the browser or the pipeline — which is where the controls in this topic apply.

A Baseline Header Set

Every static site should ship these headers on every HTML response. They cost nothing in performance, and none of them requires changes to page content:

# _headers (Cloudflare Pages / Netlify syntax)
/*
  Strict-Transport-Security: max-age=31536000; includeSubDomains
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  X-Frame-Options: DENY
  Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()
  Cross-Origin-Opener-Policy: same-origin

What each does, in one line: HSTS tells browsers to use HTTPS for your domain for a year, closing the downgrade window on the first plain-HTTP request. nosniff stops browsers guessing content types, so a file served as text/plain is never executed as script. Referrer-Policy stops full URLs — which may include query parameters with tokens — leaking to other sites. X-Frame-Options (and the CSP frame-ancestors directive that supersedes it) prevents your pages being framed for clickjacking. Permissions-Policy disables powerful browser features no page on the site uses, so an injected script cannot request them. COOP isolates your browsing context from cross-origin windows it opens.

On a documentation site, adding this set moved the Mozilla Observatory grade from F to B, and the securityheaders.com grade from F to A-, with zero content changes. The remaining gap in both was the Content-Security-Policy.

Content-Security-Policy

CSP is the most powerful header and the one that takes real work, because it describes which sources of script, style, image, font and connection the page may use. A good policy makes an injected <script> or a compromised third-party script unable to load code from anywhere unexpected or to send data anywhere unexpected.

Static sites are well suited to strict CSP because their HTML is fixed at build time: every inline script can be hashed during the build, so the policy can forbid unsafe-inline entirely.

Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'sha256-Qm9vdHN0cmFwIHRoZW1lIHNjcmlwdA==' https://plausible.io;
  style-src 'self';
  img-src 'self' data: https://img.example-cdn.net;
  font-src 'self';
  connect-src 'self' https://plausible.io;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self' https://forms.example.com;
  upgrade-insecure-requests

Writing one from scratch and rolling it out safely is covered in Writing a Content Security Policy for a Static Site; computing the hashes automatically at build time for Astro's inline scripts is in Hash-Based CSP for Inline Scripts in Astro.

What a strict CSP allows and blocks A page's script requests pass through a CSP check. The site's own bundle from self is allowed. An inline theme script whose hash is listed is allowed. The analytics script from an allow-listed origin is allowed. An injected inline script without a matching hash is blocked. A script from an unknown origin is blocked. A data exfiltration request to an unlisted origin is blocked by connect-src. The policy is an allow-list the browser enforces CSP check per request /_astro/app.js ('self') inline theme script (hash) plausible.io/js (listed) injected inline <script> evil.example/x.js allowed (3) own code, hashed inline, listed vendor blocked (2) and reported to report-to
Hashing inline scripts at build time is what lets a static site drop unsafe-inline, the directive that makes most real-world policies ineffective.

HSTS and Preload

Strict-Transport-Security only protects visitors after their first successful HTTPS visit, because they must receive the header once. The HSTS preload list, built into browsers, removes that first-visit gap by hard-coding your domain as HTTPS-only. Getting onto it requires max-age of at least a year, includeSubDomains and the preload directive — and it is hard to reverse, because removal propagates through browser releases over months. The staged rollout that avoids locking out a forgotten HTTP-only subdomain is in Enabling HSTS and Preload Safely.

Subresource Integrity for Third-Party Assets

If your pages load a script or stylesheet from a CDN you do not control, SRI lets the browser verify the file's hash before executing it. A tampered file fails the check and does not run.

<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"
        integrity="sha384-…" crossorigin="anonymous" defer></script>

SRI only works for versioned, immutable URLs — a latest URL changes content and breaks the hash. On static sites the better default is to self-host third-party code at build time, which removes the external origin entirely; SRI is for the cases where that is not possible. The details, including generating hashes in the build, are in Subresource Integrity for Third-Party Assets.

The Pipeline: Dependencies and Deploy Credentials

Response headers cannot help if the attacker publishes the page. For static sites, two pipeline risks dominate.

Dependencies. A typical Astro or Next.js site installs 400–1,200 npm packages. Every one can run code during npm install (via lifecycle scripts) and during the build, with access to environment variables — including deploy tokens if they are present. Lockfiles with integrity hashes, npm ci instead of npm install, disabling lifecycle scripts where possible, automated audit in CI, and a review step for new dependencies reduce this risk substantially; see Auditing npm Dependencies in SSG Pipelines.

Deploy credentials. A long-lived API token stored as a CI secret is a standing key to every page on the site. Short-lived credentials issued via OpenID Connect, scoped to one project and one branch, cannot be reused if leaked and expire within minutes. The setup for GitHub Actions to Cloudflare, AWS and Netlify is in Securing Deploy Credentials with GitHub OIDC.

Where build-time code runs and what it can reach The CI job runs npm ci, which executes install scripts from hundreds of packages, then the build, which executes plugin and bundler code, then the deploy step. If a long-lived deploy token is in the job's environment from the start, every earlier step can read it. With OIDC, the credential is minted only in the deploy step and expires in minutes. Keep the publish credential away from third-party code npm ci ~900 packages' scripts build plugins, bundler, loaders deploy first-party step only long-lived token in job env: readable by every step, valid for months OIDC: minted here, ~15 min A dependency cannot steal a credential that does not exist yet when it runs
Moving credential issuance into the deploy step shrinks the window from "whole job, for months" to "one step, for minutes".

Headers Beyond the Baseline

A few more headers are worth knowing about, even if not every site needs them.

Cross-Origin-Resource-Policy (same-origin or same-site) stops other sites embedding your images, scripts and fonts as subresources. It protects against a class of side-channel attacks and, as a side benefit, stops hotlinking of large assets. Apply it to asset paths, not to pages meant to be linked.

Cross-Origin-Embedder-Policy is only needed if the site uses features such as SharedArrayBuffer. Setting it without that need tends to break third-party embeds, so leave it unset on most content sites.

Cache-Control on sensitive responses. Static sites rarely serve personal data, but preview deployments sometimes contain unreleased content. X-Robots-Tag: noindex on preview hostnames, plus access control, keeps drafts out of search engines and shared caches; see Password-Protecting Preview Deployments.

Remove what you do not need. Many hosts and frameworks add headers that advertise software versions (X-Powered-By, Server with a version string). They give attackers free reconnaissance and nothing to readers. Strip them where the host allows.

Finally, prefer one source of truth. Headers scattered across a _headers file, a meta tag and a middleware function drift apart; generate the _headers file from a single configuration object at build time so the policy is reviewable in one diff.

Setting Headers on Each Host

The same header set translates directly between hosts:

HostWhere headers liveNotes
Cloudflare Pages / Workers static assets_headers file in the output directorypath patterns with *; later rules add to earlier ones
Netlify_headers file or [[headers]] in netlify.tomlsame _headers syntax as Cloudflare
Vercelheaders array in vercel.jsonsource patterns; applies to static and functions
S3 + CloudFrontResponse headers policymanaged "SecurityHeadersPolicy" as a starting point
nginx / Caddyadd_header / header directivesnginx drops parent add_headers in nested blocks unless repeated

The nginx behaviour catches many self-hosters: an add_header in a location block replaces, rather than extends, the ones set at the server level, so a location that adds a Cache-Control header silently loses all the security headers. Repeat them or use an include file, as shown in Serving a Static Site with Nginx.

Verifying Headers in CI

Headers are configuration, so test them like configuration. A smoke test against each preview deploy can assert that every template returns the full set:

for path in / /guides/ /guides/deploying-hugo/ /404.html; do
  h=$(curl -sI "$PREVIEW$path")
  for want in strict-transport-security content-security-policy x-content-type-options referrer-policy; do
    echo "$h" | grep -qi "^$want:" || { echo "FAIL $path missing $want"; exit 1; }
  done
done

This belongs in the same job as the smoke tests from Running Smoke Tests Against a Preview URL. The 404 page is included deliberately: error responses are frequently served by a different code path that skips header rules.

Collecting and Reading CSP Reports

A policy without reporting is a policy you cannot improve. Add a report-to directive (with a matching Reporting-Endpoints header) and, for older browsers, report-uri, pointing at an endpoint you control. A small edge function that validates the JSON, drops obviously malformed reports, and writes the rest to a log store is enough; the pattern is the same as the form handler in Handling Form Submissions on a Static Site.

Expect noise. On the documentation site measured below, 94% of reports in the first week came from browser extensions injecting scripts and styles into every page — password managers, ad blockers, translation tools — identifiable by source-file values such as chrome-extension or moz-extension. Filter those out before anyone reads the data. What remains is signal: a forgotten inline script in one template, a vendor that moved its assets to a new domain, an embed that loads a font from a CDN you never listed. Group reports by effective-directive and blocked-uri, and review the top ten weekly until the list is empty except for extensions.

Keep reporting on after switching to enforcing mode. A spike in violations after a deploy is one of the fastest indicators that something changed that should not have — either a legitimate change nobody updated the policy for, or an injection attempt the policy just blocked.

Embeds, Forms and Other Exceptions

Most static sites have a handful of features that do not fit a strict policy by default. Handle each one deliberately rather than loosening the whole policy.

Video and social embeds need frame-src entries for their domains (https://www.youtube-nocookie.com, https://player.vimeo.com). Loading them behind a click-to-load facade, as in Lazy-Loading YouTube Embeds on Static Sites, is also a security win: the third-party frame never loads for readers who do not ask for it.

Forms posting to a third-party service need that origin in form-action. Posting to your own edge function instead keeps form-action 'self' intact and keeps submissions under your control.

Search tools that use WebAssembly, such as Pagefind, need 'wasm-unsafe-eval' in script-src. That keyword permits compiling WebAssembly only, not JavaScript eval, so it is a narrow and acceptable exception.

Analytics and consent managers are the hardest. Many inject inline scripts at runtime, which no build-time hash can cover. Prefer vendors that load as a single external script, self-host their code where the licence allows, and treat any vendor that requires unsafe-inline or unsafe-eval as a cost to be justified in writing.

Scope exceptions by path where the host allows it. On Cloudflare Pages or Netlify, a _headers rule for /videos/* can extend frame-src for the few pages that embed video, while every other page keeps the stricter policy.

When Something Goes Wrong

Static sites make incident response unusually simple, and it is worth writing the steps down before they are needed. If a compromised dependency or a leaked token has published malicious content, the order is: roll back to the last known-good deploy (on atomic hosts this takes seconds, as described in Rolling Back a Bad Static Deploy in Under a Minute); revoke every credential the pipeline could reach; purge the CDN cache so no edge keeps serving the bad version; and only then investigate. Because every deploy is an immutable artifact tied to a commit, the investigation can diff the bad deploy's output against the previous one file by file to see exactly what was injected. Keep at least thirty days of deploy history for this reason, and make sure the rollback does not depend on the same credential that may have leaked.

Measured Impact

A 700-page Astro documentation site on Cloudflare Pages applied the full set over three weeks: baseline headers, a hash-based CSP rolled out via report-only, HSTS with preload, self-hosted third-party scripts, OIDC deploys and dependency auditing.

MeasureBeforeAfter
Mozilla Observatory score0/100 (F)115/100 (A+)
securityheaders.com gradeFA+
Inline scripts allowed without hashall0
Third-party script origins41
Long-lived deploy secrets in CI20
Known-vulnerable dependencies (high/critical)70
CSP violation reports per day (enforcing, week 4)3–8, all browser extensions
Change in LCP p75none measurable

Common Pitfalls

  • Enforcing CSP on day one. Always ship Content-Security-Policy-Report-Only first and read the reports.
  • unsafe-inline for convenience. It defeats most of the protection. Hash inline scripts at build time instead.
  • HSTS preload before auditing subdomains. An internal tool on plain HTTP becomes unreachable for months.
  • Headers only on HTML. Error pages and some assets are served via different rules; check 404.html and redirects explicitly.
  • Secrets available to every CI step. Scope deploy credentials to the deploy step, and prefer OIDC to stored tokens.
  • Treating grades as the goal. Observatory and securityheaders.com are checklists, not threat models. The CSP and the pipeline controls are where the real risk reduction is.

Key Takeaways

  • Static sites still run third-party JavaScript and still have a publishing pipeline; both need hardening.
  • A baseline of HSTS, nosniff, Referrer-Policy, frame protection and Permissions-Policy costs nothing and belongs on every site.
  • A hash-based CSP without unsafe-inline is achievable on static sites because inline scripts are known at build time.
  • Self-host third-party code where possible; use SRI where it must stay external.
  • Dependency auditing and short-lived OIDC deploy credentials protect against the attacks headers cannot stop.

FAQ

Does a static site need security headers at all?

Yes. There is no server-side code to exploit, but browsers still execute whatever scripts the page loads, including injected or compromised third-party scripts. Headers such as Content-Security-Policy and HSTS limit what an attacker can do if content or a dependency is tampered with.

Which headers matter most?

Strict-Transport-Security, a Content-Security-Policy, X-Content-Type-Options set to nosniff, a Referrer-Policy, and frame-ancestors or X-Frame-Options against clickjacking. A Permissions-Policy that disables unused browser features is a useful addition.

Where are headers configured on a static host?

In the host's header configuration rather than in HTML: a _headers file on Cloudflare Pages and Netlify, the headers key in vercel.json, response header policies on CloudFront, or add_header directives in nginx. A few, such as CSP, can also be set with a meta tag, with limitations.

What is the biggest real risk for a static site?

The build pipeline and its dependencies. A compromised npm package or leaked deploy token can publish malicious content to every page. Pinning dependencies, auditing them and using short-lived deploy credentials matter as much as response headers.

Will a strict CSP break my site?

It can if it is deployed in enforcing mode without testing. Roll it out with Content-Security-Policy-Report-Only first, collect violation reports for a week or two, fix or allow what is legitimate, then switch to enforcing.