Hardening a Static Site with Security Headers

Table of Contents

  1. Content-Security-Policy
  2. The directives that have no fallback
  3. X-Frame-Options
  4. X-Content-Type-Options
  5. Cache-Control
  6. Why meta tags instead of HTTP headers?
  7. Testing
  8. The takeaway

I just finished adding security headers to this site and figured the process was worth documenting — because the most dangerous assumption in static site development is that “static” means “safe.”

There is no database here. No login form. No server-side code. Jekyll generates HTML files, GitHub Pages serves them, and that is the entire stack. So why bother with security headers? Because the browser does not know any of that. It will execute injected scripts, render your page inside a hostile iframe, and MIME-sniff text into JavaScript unless you tell it not to. The headers are how you tell it not to.

Content-Security-Policy

The CSP header is the big one. It tells the browser exactly which origins are allowed to supply scripts, styles, fonts, images, and frames for your page. Everything else gets blocked.

<meta http-equiv="Content-Security-Policy" content="
  default-src 'self';
  script-src 'self' 'unsafe-inline' https://www.googletagmanager.com
              https://www.google-analytics.com;
  style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
  font-src 'self' https://fonts.gstatic.com;
  img-src 'self' data: https:;
  connect-src 'self' https://www.google-analytics.com
              https://analytics.google.com;
  frame-src 'self' https://www.youtube.com
            https://www.youtube-nocookie.com;
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';
">

Each directive scopes a resource type to a set of trusted origins. default-src 'self' is the baseline — only load resources from this domain. The other directives override that baseline for specific resource types that need external origins (Google Fonts, Google Analytics, YouTube embeds).

script-src includes 'unsafe-inline' because the Jekyll theme uses an inline <script> block for theme toggling. In a perfect world you would extract that to a file and use a nonce or hash instead. Pragmatism won this round.

The directives that have no fallback

This is the part that trips people up. Three CSP directives do not fall back to default-src when omitted:

Directive Controls If missing
base-uri Allowed <base href> values Any origin — attacker hijacks all relative URLs
form-action Where forms can submit Any endpoint — input exfiltration
frame-ancestors Who can iframe your page Anyone — clickjacking

Excluding them is identical to setting them to *. I initially shipped the CSP without these three and only caught the gap on review. It is an easy mistake to make because every other directive inherits from default-src, so you develop a false sense of coverage.

X-Frame-Options

<meta http-equiv="X-Frame-Options" content="DENY">

The original anti-clickjacking header. It prevents any site from embedding your page in an iframe. The CSP frame-ancestors 'none' directive does the same thing, but X-Frame-Options covers older browsers that do not support CSP Level 2. Defense in depth — keep both.

X-Content-Type-Options

<meta http-equiv="X-Content-Type-Options" content="nosniff">

Browsers sometimes ignore the Content-Type header and guess based on file contents. This is called MIME sniffing, and it can turn a harmless text file into executable JavaScript. nosniff disables that behavior entirely.

Cache-Control

<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">

Forces revalidation on every request. On a portfolio site this is less about security and more about correctness — when you push a fix, visitors should see it immediately, not a cached version from three days ago.

Why meta tags instead of HTTP headers?

GitHub Pages does not give you control over HTTP response headers. The <meta http-equiv> tag is the workaround. It is not as strong as a real HTTP header — an intermediary proxy could strip it — but browsers do respect it, and for a static site on GitHub Pages it is the available mechanism.

Place your security meta tags early in <head>, before any <script> or <link> tags. The browser needs to see the policy before it starts loading resources, otherwise the first few loads happen under the default permissive rules.

Testing

After deployment, verify your headers are doing what you think:

  • securityheaders.com — grades your response headers A through F
  • CSP Evaluator — Google’s tool for analyzing CSP weaknesses
  • curl — quick check from the terminal:
curl -sI https://yoursite.github.io | grep -iE "content-security|x-frame|x-content|cache"

The takeaway

Static sites skip the server-side attack surface, but the client-side surface is identical to any other website. The browser will execute whatever it is told to execute unless you set boundaries. Four meta tags in your <head> is the difference between an open door and a locked one.

The full writeup with implementation details lives at github.com/rifezacharyd/security/hardening-static-sites.


If you are running a Jekyll site on GitHub Pages and want to compare notes, the source for this site’s head.html is public — feel free to pull it apart.

Hardening a Static Site with Security Headers