---
title: 'Security headers for an Astro site behind Caddy'
description: 'How I hardened my site with a strict Content Security Policy, clean response headers, and a GDPR-compliant configuration — and solved the Astro inline-script gotcha along the way.'
pubDate: 'Apr 22 2026'
category: en/tech
tags:
  - security
  - caddy
  - astro
  - csp
  - privacy
  - gdpr
translationKey: security-headers-astro-caddy
---

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

- **[Webbkoll](https://webbkoll.5july.net/en/check)** by the Swedish digital rights organisation [Dataskydd.net](https://dataskydd.net/).
- **[securityheaders.com](https://securityheaders.com/)** for a compact grade report (A+ to F).
- **[Mozilla HTTP Observatory](https://developer.mozilla.org/en-US/observatory)** for a deeper CSP check.

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:

```caddy
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:

```bash
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:

```astro
<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`:

```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:

```astro
<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`:

```caddy
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](https://webbkoll.5july.net/en/check) scan:

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

On [securityheaders.com](https://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](/en/local-webmention-avatars/).

## 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](https://webbkoll.5july.net/en/check) gives you the matching GDPR article for every finding; [securityheaders.com](https://securityheaders.com/) is the quick sanity check; [Mozilla HTTP Observatory](https://developer.mozilla.org/en-US/observatory) is the deepest CSP analysis.
