Security headers for an Astro site behind Caddy

Category: Tech

Tags: security, caddy, astro, csp, privacy, gdpr


Once the site was live, I ran it through a few scanners:

The results were mixed: zero cookies, zero third-party requests, HTTPS by default — but no Content Security Policy, no HSTS, no Referrer-Policy. The foundation was good; the helmet was missing.

This post is structured chronologically as Problem → Implementation → Solution: each section describes what the scan found, how I approached it, and what came out the other end.

Why Webbkoll specifically

Webbkoll isn’t a generic security scanner. For every check, it explains which article of the GDPR backs it up:

  • Art. 5.1.c (data minimisation) — the basis for demanding a restrictive Referrer-Policy and no third-party requests.
  • Art. 25 (Privacy by Design and by Default) — the entire rationale behind “zero cookies, zero third-party calls without consent”.
  • Art. 32 (security of processing) — covers the classic security headers: HSTS, CSP, X-Content-Type-Options, X-Frame-Options.

That makes Webbkoll my scanner of choice when the goal isn’t just “secure” but being able to demonstrate that the site was built GDPR-compliant. A securityheaders.com grade is a pretty marketing badge; a green Webbkoll scan is evidence for a clean Art. 25 paper trail.

Why any of this matters

A static site with no login, no forms, no user input looks like an uninteresting target at first glance. The relevant protections are still easy to name:

  • Content Security Policy is insurance against the day foreign HTML ends up on the site — through a supply-chain compromise in a dependency, a misconfiguration, a typo in a template. Without CSP the browser executes any injected JavaScript. With a strict CSP it blocks it.
  • HSTS protects readers from downgrade attacks. Someone on open Wi-Fi typing adrian-altner.de without a protocol into the address bar would otherwise hit HTTP first and be exposed in that moment.
  • Referrer-Policy is pure privacy hygiene for readers. Without it, their browser transmits the URL they were on when they click an external link. That can be highly sensitive.

The setup

  • VPS at Hetzner, Debian.
  • Caddy as a reverse proxy in front of a container (nginx serving the static Astro build on 127.0.0.1:4321).
  • Astro 6 as the static site generator, localised in German and English.

Security headers in the Caddyfile

Problem: Caddy ships only the bare-minimum headers out of the box — no CSP, no HSTS, no Referrer-Policy. The browser runs with its most permissive defaults.

Implementation: All relevant directives go into a header block in the site config:

adrian-altner.de, www.adrian-altner.de {
    encode zstd gzip

    @www host www.adrian-altner.de
    redir @www https://adrian-altner.de{uri} permanent

    header /fonts/* Cache-Control "public, max-age=31536000, immutable"
    header /_astro/* Cache-Control "public, max-age=31536000, immutable"
    header /images/* Cache-Control "public, max-age=604800"

    header {
        Content-Security-Policy "default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
        Referrer-Policy "no-referrer"
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        X-XSS-Protection "0"
        Permissions-Policy "geolocation=(), microphone=(), camera=(), interest-cohort=()"
        Cross-Origin-Opener-Policy "same-origin"
        -Server
    }

    reverse_proxy 127.0.0.1:4321
}

Validate and reload without a full restart:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

Solution: A curl -I against the site now shows the complete set of headers. Some notes on the less obvious lines:

  • default-src 'none' is stricter than 'self'. It forces you to whitelist every directive explicitly — nothing falls back silently to a looser default.
  • X-XSS-Protection: 0 looks odd but is current OWASP guidance: the legacy XSS auditor in older browsers introduced more holes than it patched. Explicitly setting 0 disables it.
  • X-Frame-Options: DENY is technically redundant next to CSP frame-ancestors 'none', but provides a second layer for older browsers without CSP Level 2 support.
  • -Server removes the Server header and with it a small fingerprinting vector.
  • interest-cohort=() in the Permissions-Policy explicitly opts out of Google’s FLoC, even though the feature is effectively dead.

The CSP and Astro’s inline-script gotcha

Problem: The first re-check revealed a fresh break: /contact/ no longer worked. On the contact page the email address is obfuscated (base64-encoded in a data-obf attribute) and decoded client-side. The decode snippet lived inline in the .astro file:

<script>
  function decode() {
    document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
      el.textContent = atob(el.dataset.obf!);
      el.removeAttribute('data-obf');
    });
  }
  decode();
  document.addEventListener('astro:page-load', decode);
</script>

In production Astro inlined it as <script type="module">...</script>. My strict script-src 'self' blocked it — correctly. Three options:

  1. Allow 'unsafe-inline' — makes the CSP practically worthless, out.
  2. Add a SHA-256 hash of the script to the CSP — has to be verified after every build, fragile.
  3. Move the script into its own file and import it — Astro always bundles module imports as external JS files, which script-src 'self' allows without issue.

Implementation: Option 3. New file src/scripts/decode-obf.ts:

function decode() {
  document.querySelectorAll<HTMLElement>('[data-obf]').forEach((el) => {
    el.textContent = atob(el.dataset.obf!);
    el.removeAttribute('data-obf');
  });
}
decode();
document.addEventListener('astro:page-load', decode);

And in the contact page:

<script>
  import '~/scripts/decode-obf';
</script>

Solution: After the build the script lands in the output as a hashed _astro/...js file, referenced via <script type="module" src="..."> — CSP-compliant and with the correct Cache-Control header.

About style-src 'unsafe-inline'

That one stays. Astro renders scoped <style> tags per component, the @font-face declaration, and view-transition keyframes all inline. Without SSR middleware injecting a per-request nonce, there’s no clean way around it on a static site. The tradeoff is acceptable: style injection doesn’t allow code execution — it’s meaningfully less dangerous than script injection. Most Astro sites hit this exact wall.

The referrer header

Problem: My first iteration set Referrer-Policy: strict-origin-when-cross-origin — a reasonable default that only sends the origin (not the full URL). Webbkoll still flags it as a yellow warning: on HTTPS-to-HTTPS requests the origin is transmitted.

Implementation: Since the site has no analytics and nobody needs the referrer, I switched to no-referrer:

Referrer-Policy "no-referrer"

Solution: Browsers won’t send a Referer header at all when clicking external links (Mastodon, GitHub, webmention profiles). Strictest possible setting, no downside for a content site — and a clean Art. 5.1.c trail (data minimisation).

The outcome

The final Webbkoll scan:

  • HTTPS by default: ✓
  • Content Security Policy: ✓ “Good policy”
  • Referrer Policy: ✓ “Referrers are not transmitted”
  • Cookies: 0
  • Third-party requests: 0

On securityheaders.com the grade is an A+. The only red CSP check is style-src 'unsafe-inline' — the Astro tradeoff discussed above.

What was still open

One finding stayed unresolved: external avatar images from webmentions were blocked by img-src 'self' data:. The fix deserves its own short post because it doesn’t just solve a CSP problem but also protects readers from third-party requests — caching webmention avatars locally at build time.

What to take away

  • Security headers in Caddy are four lines. There’s no good reason not to set them.
  • default-src 'none' instead of 'self' forces you to whitelist every directive deliberately. You learn what your site actually loads in the process.
  • Avoid inline scripts. Once CSP gets strict, you need to extract them anyway — Astro bundles module imports as external files automatically.
  • no-referrer is a sensible default for content sites. If you don’t run analytics or conversion tracking, you lose nothing — and you minimise data in line with GDPR.
  • Scanners aren’t a luxury, they’re evidence. Webbkoll gives you the matching GDPR article for every finding; securityheaders.com is the quick sanity check; Mozilla HTTP Observatory is the deepest CSP analysis.