Skip to content

·  · 7 min read

Security Headers for a Static Site: The 10 Lines I Added to vercel.json

No server does not mean nothing to configure. These are the four response headers I added to my static site, what each one blocks, and the one I left out on purpose.

No server does not mean nothing to configure. These are the four response headers I added to my static site, what each one blocks, and the one I left out on purpose.

My portfolio is a static Astro site on Vercel. No server, no database, no sessions, no login. For a long time I treated that as “nothing to secure”, and I suspect most people running static sites do the same. Then I ran curl -I against my own domain and read what actually came back. Almost nothing. This post is the exact setup I now use for security headers for a static site: four declarative lines in vercel.json, what each one really does, what it does not do, and how to verify the result. Total effort: about ten minutes.

A static site is not zero attack surface

Let me be honest about the threat model first, because security posts love to exaggerate. A static site cannot be SQL-injected. There is no auth to bypass, no session to steal. The risk is genuinely smaller than for an app with a backend.

But your pages still run in browsers, and browsers make real security decisions based on the response headers you send. With zero headers configured:

  • Any site on the internet can load your pages inside an iframe and build a clickjacking overlay on top of them (your contact form, for example).
  • Browsers are allowed to guess content types instead of trusting what the server declared.
  • Every outbound link you publish can leak the full URL of the page the visitor came from to the destination site and its analytics.
  • Any script on your pages, including third-party scripts you add later, can ask for the camera, the microphone, or the visitor’s location.

None of that is catastrophic for a portfolio. All of it is free to close. That asymmetry is the whole argument.

The security headers for a static site config I shipped

This is the real vercel.json running on this site right now, not a sample:

{
  "cleanUrls": true,
  "trailingSlash": false,
  "redirects": [{ "source": "/en", "destination": "/", "permanent": false }],
  "headers": [
    {
      "source": "/_astro/(.*)",
      "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
    },
    {
      "source": "/(.*)",
      "headers": [
        { "key": "X-Content-Type-Options", "value": "nosniff" },
        { "key": "X-Frame-Options", "value": "SAMEORIGIN" },
        { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
        {
          "key": "Permissions-Policy",
          "value": "camera=(), microphone=(), geolocation=(), interest-cohort=()"
        }
      ]
    }
  ]
}

That is the entire mechanism. There is no server code and no middleware, because there is nothing to run it on. Vercel reads this file at deploy time and its edge attaches the headers to every response that matches /(.*), which is everything. Declarative hardening on a site with no runtime. That is the appeal.

X-Content-Type-Options: nosniff

This tells the browser to trust the declared Content-Type and never guess. Without it, browsers can “sniff” the bytes of a response and decide, for instance, that something is executable script even though it was not served as one. That guessing behavior is the basis of a whole family of old attacks.

What it does not do: it will not fix a wrong content type. If a file is served with the wrong type, nosniff makes it fail loudly instead of silently working. That is the correct behavior. On a static site where Vercel already serves correct types, this header costs you nothing and closes the guessing path for good.

X-Frame-Options: SAMEORIGIN, and why not DENY

This controls who can put your pages inside an iframe. DENY means nobody, including you. SAMEORIGIN means only pages served from your own origin.

Honesty note: DENY is the stricter setting, and if you are certain you will never embed your own pages anywhere, it is the better default. I picked SAMEORIGIN deliberately, so I can iframe my own pages (previews, embedded demos) without touching the config again. Both settings stop the attack that actually matters here, which is a third-party site framing yours to trick visitors into clicking things they cannot see. The threat is cross-origin framing, and both values block it.

Referrer-Policy: strict-origin-when-cross-origin

When a visitor clicks a link on your site, the browser tells the destination where the visitor came from. This policy says: send the full URL for same-origin navigation, send only the origin (https://heroweb.dev, no path) for cross-origin navigation, and send nothing at all if the destination downgrades to HTTP.

Modern browsers already use this as their default, so why set it? Two reasons. Older browsers had leakier defaults, and an explicit header pins the behavior everywhere. And it documents intent: anyone reading the config knows referrer leakage was considered, not forgotten. On a blog, paths and query strings are mostly harmless, but there is no reason to broadcast them to every external site I link to.

Permissions-Policy: turning off what I never use

camera=(), microphone=(), geolocation=() sets an empty allowlist for each of those browser APIs. No code running on my pages can request them. Not my code, and, more importantly, not a third-party script I might add in six months and forget to audit. That is the real value here: it is a guardrail against future me, not against my current code.

The odd one out is interest-cohort=(). That opts the site out of Google FLoC, the cohort-based ad tracking experiment. FLoC is dead (its successor is the Topics API), so this is mostly a legacy opt-out at this point, but it costs nothing to keep and it states a position: my visitors are not ad-targeting input.

Where is Strict-Transport-Security?

Not in the file, and that is on purpose, not an oversight. Vercel adds the HSTS header automatically at the platform edge for custom domains. Run curl -I against the site and you will see strict-transport-security in the response even though my config never mentions it. Duplicating it manually would add a line I would then have to keep consistent with whatever the platform sends. If you host somewhere that does not inject HSTS for you, add it yourself; on Vercel it is already handled.

While you’re in there: immutable caching for hashed assets

The /_astro/(.*) block is not a security header, but it lives in the same file and it is two lines, so take the win. Astro fingerprints its build assets, meaning the filename contains a hash of the content. If the content changes, the URL changes. So a given URL can never serve different bytes, which makes max-age=31536000, immutable (cache for a year, never revalidate) completely safe. Repeat visitors stop re-downloading your CSS and JS entirely.

This file is also where cleanUrls and my redirects live, which is the same category of cheap, declarative site hygiene as the Astro SEO setup most portfolios skip. One JSON file, several unglamorous wins.

How to verify it actually shipped

Config you have not verified is config you hope is working. Two checks:

curl -I https://heroweb.dev

Read the response and confirm all four headers are present (plus the platform-added HSTS). Then run the site through securityheaders.com, which grades the header set and tells you exactly what is missing. I have the same rule for content: if I did not check it before publishing, it is not done.

What these headers do not do

Defense in depth, not a force field. These headers will not protect an API key you accidentally shipped in a client bundle, and they will not stop a compromised third-party script from doing harm inside your page. That second problem is Content-Security-Policy territory, and I have not shipped a CSP yet: doing it properly on a site with inline scripts takes real work, and a sloppy CSP breaks your own site while stopping nothing. It is on the list.

What I did ship is ten minutes of copy-paste that closes clickjacking, MIME sniffing, referrer leakage, and unwanted browser API access on every page. Small chores like this are exactly what sits in a backlog forever, which is why this kind of task is what I hand to an AI coding agent that works overnight. However you get it done, get it done. It is the cheapest hardening your static site will ever get.

    Share:

    Enjoyed this post?

    Get new posts on web dev, AI and SEO straight to your inbox. No spam, unsubscribe anytime.

    No spam. By subscribing you agree to the privacy policy .

    Back to blog

    Related Posts

    View All Posts »
    How I Cut My Homepage Image Weight 88% (Astro + Sharp + WebP)

    How I Cut My Homepage Image Weight 88% (Astro + Sharp + WebP)

    A static Astro site with 15KB of JavaScript was shipping 3.4MB of images. Here are the three failure modes that got past the image pipeline — the fallback-always-ships rule, the screenshot the optimizer never touched, and the boilerplate nobody referenced — and the 15 lines of Sharp that fixed all of it.