A static site on GitHub Pages doesn’t have much to attack: no logins, no forms, no cookies, no database. But “not much” isn’t nothing, so I went through the whole setup (the domain, the build pipeline and the browser side) and fixed what was worth fixing. Below are the changes in severity order, what attack each one stops, and the couple of things I decided weren’t worth it.

#ChangeAttack it stopsSeverity before
1SPF + DMARC recordsAnyone sending email “from” my domainMedium
2Hardened build pipelineA tampered tool or compromised step changing the live siteMedium (unlikely, high impact)
3Content-Security-PolicyInjected scripts running on my pagesLow to Medium
4HSTSFirst-visit interception on hostile Wi-FiLow (TBD)
5CAA recordsA certificate for my domain issued by the wrong authorityLow

Email Spoofing: SPF And DMARC

This was the biggest open gap, and honestly not one I expected going in. I don’t send any email from erikfailing.com, so I never thought about it, but that’s exactly the problem. With no email records at all, nothing told receiving mail servers that mail “from” my domain was fake. Anyone could send a message as erik@erikfailing.com to a recruiter or a colleague, and the receiving server had no published policy saying to reject it.

For a domain that never sends mail, the fix is two DNS records that say “nothing is allowed to send as me, and reject anything that tries”:

erikfailing.com         TXT  "v=spf1 -all"
_dmarc.erikfailing.com  TXT  "v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s;"

SPF with -all lists zero allowed senders, and DMARC with p=reject tells every receiver that checks (Gmail, Outlook, Yahoo, etc…) to reject mail that fails. Since I don’t send mail from this domain, there’s nothing for these records to break. Gmail already spam-folders a lot of unauthenticated mail on its own, but that’s Gmail’s call; these records make it my explicit policy everywhere.

The Build Pipeline

I think this is the most interesting one, because it’s the only path to something actually bad. Anyone who can change what the build produces controls the live site, and no browser header can stop that, since they’d just remove the header too. Supply chain attacks on CI are real: in March 2025, the popular tj-actions/changed-files GitHub Action had its version tags repointed to malicious code, and every workflow using it ran that code on its next build.

So I tightened the GitHub Actions workflow in a few ways:

  • Removed tools I wasn’t using. The workflow I started from (Hugo’s official one) downloaded Dart Sass on every build and put it on the PATH, without verifying it. My site doesn’t use Sass at all, so that was a free download of attack surface. Go and Node steps went too.
  • Verified the Hugo download. The workflow now checks Hugo’s tarball against a SHA-256 hash from the release’s checksums file, written into the workflow itself. If the download ever doesn’t match, the build fails before Hugo is even unpacked:
echo "${HUGO_SHA256}  ${{ runner.temp }}/hugo_${HUGO_VERSION}_linux-amd64.tar.gz" | sha256sum --check --strict
  • Split permissions by job. Before, the whole workflow had the right to publish the site, including the build job where Hugo and the theme’s code run. Now the build job can only read the repo, and only the small deploy job (which just runs GitHub’s own deploy action) can publish.
  • Don’t leave the token lying around. actions/checkout keeps the job’s GitHub token on disk by default, where every later step can read it. Nothing after checkout needs it, so persist-credentials: false removes it.
  • Dependabot for updates. It opens a pull request when the actions or the theme have updates, so nothing goes stale and nothing changes without me reviewing it.

I didn’t pin actions to commit SHAs. All of them are GitHub’s own, and pinning would mean an update pull request for every minor release. That’s a trade-off I’m fine with for now.

Content-Security-Policy

A CSP tells the browser where scripts, styles, fonts and images are allowed to come from. Mine only allows the site itself:

default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self';
connect-src 'self'; manifest-src 'self'; base-uri 'self'; form-action 'none'

With no user input, there’s no classic XSS path here. What the CSP covers is content-level injection: a bad embed pasted into a post, or a malicious contribution that adds a script tag. Those get blocked, along with any inline script or third-party resource.

Two catches made this more work than a one-liner:

  • GitHub Pages can’t send custom headers, so the CSP lives in a <meta> tag. That works for most directives, but not frame-ancestors (clickjacking protection), and not reporting.
  • No 'unsafe-inline' meant cleaning up the theme first. PaperMod had three inline scripts, which I moved into one self-hosted JS file with a subresource integrity hash. Hugo’s syntax highlighting wrote colors as inline style="" attributes (75 of them on one post), so I switched it to CSS classes.

To be honest about its limits: anyone who controls the build (point 2) can just edit the meta tag, so the CSP is defense in depth, not a wall. I tested it by loading every page in headless Chrome with zero violations, and with a positive control page to make sure violations were actually being caught.

HSTS (TBD)

HSTS tells browsers “only ever use HTTPS for this domain.” Without it, the very first request to http://erikfailing.com (say, someone typing the address on hotel Wi-Fi) goes out unencrypted before GitHub’s redirect to HTTPS. An attacker on that network can answer it themselves with a fake copy of the site, a phishing link, or a cryptominer. Hotspot and ISP script injection has happened for real (Comcast’s Wi-Fi hotspots injected ads into pages back in 2014). Browsers trying HTTPS first by default shrinks this gap, but doesn’t close it.

GitHub Pages can’t send custom headers, and HSTS can’t be set from a meta tag. My first support ticket asking GitHub to enable it got auto-closed because I was on the free plan, so I upgraded to Pro and reopened it lol.

TBD: waiting on GitHub Support.

CAA Records

Any of the hundreds of certificate authorities browsers trust can issue an HTTPS certificate for any domain, as long as the requester passes that authority’s check. A CAA record limits it to the ones I name. GitHub Pages gets its certificates from Let’s Encrypt, so that’s the only one:

erikfailing.com  CAA  0 issue "letsencrypt.org"
erikfailing.com  CAA  0 issuewild ";"

The second line blocks wildcard certificates entirely, since I don’t need one. This stops someone getting a valid certificate for my domain from a sloppier authority, which they could use to impersonate the site to someone on their network. It’s rare, but it’s two DNS records. The one thing to remember: if GitHub ever switches certificate authorities, renewals would fail until I update this.

What I Didn’t Bother With

The scanner (Mozilla HTTP Observatory) took the site from a D (30) to a C (55), which is the ceiling for meta tags on GitHub Pages. Getting to an A would mean putting Cloudflare in front of GitHub Pages or moving hosts, and I think that’s not worth it, because the remaining headers protect almost nothing on this site:

  • Clickjacking protection: there are no buttons that do anything sensitive to trick someone into clicking.
  • X-Content-Type-Options: all content is mine, so there’s nothing to sniff.
  • Permissions-Policy, COOP/CORP: no camera, mic, location, secrets, or third-party content to protect.

A few things were already in good shape too: DNSSEC was on, GitHub’s domain verification prevents someone else claiming the domain on Pages, and there are no wildcard DNS records that could leave a subdomain open to takeover.

So overall, I think the honest summary is that the browser headers were the least important part of this. The real risks for a static site are the accounts and pipeline around it, and the email records I didn’t know were missing.