How to harden a Vercel deploy with security and cache headers
A freshly deployed site works, but “works” and “hardened” are not the same
thing. By default your responses ship without any of the HTTP headers that tell
browsers to enforce HTTPS, block clickjacking, or cache your assets aggressively.
The good news: on Vercel you get all of that from one file — vercel.json — with
zero code changes. This is the exact headers block I ship, explained one line at
a time.
Where headers live on Vercel
Vercel reads a vercel.json at the root of the deployed project. A headers
array lets you attach response headers to any URL pattern. Each entry has a
source (a path pattern) and a list of headers (key/value pairs) to add when a
request matches.
// vercel.json
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"framework": "vite",
"buildCommand": "bun run build",
"outputDirectory": "dist",
"headers": [ /* ... */ ]
}The source uses path-to-regexp style matching. "/(.*)" means “every path,”
so headers you put there apply site-wide. More specific patterns (like a folder
or file extension) let you target just a subset — which is exactly how we split
security headers from cache headers below.
The security headers (applied to every path)
Here is the site-wide block. It matches "/(.*)", so every response carries
these five headers.
// vercel.json
{
"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=()" },
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" }
]
}Let me define each one, because the names are cryptic but the ideas are simple.
X-Content-Type-Options: nosniff
Browsers used to “sniff” a file’s real type by peeking at its bytes, ignoring the
Content-Type the server declared. That let an attacker upload something labelled
as an image but actually containing script, and trick the browser into running it.
nosniff turns sniffing off: the browser trusts your declared content type and
nothing else. One value, always the same — just set it.
X-Frame-Options: SAMEORIGIN
This controls whether other sites can embed your pages inside an <iframe>.
Clickjacking is the attack where a malicious page loads your real site in an
invisible frame and tricks users into clicking things they can’t see.
SAMEORIGIN means only pages from your own domain may frame you, which kills that
attack for third-party sites.
Referrer-Policy: strict-origin-when-cross-origin
When you click a link, the browser tells the destination which page you came from
(the “referrer”). That can leak full URLs — including query strings — to other
sites. strict-origin-when-cross-origin sends the full URL only within your own
site; when the request crosses to another origin it sends just the origin (e.g.
https://blog.developerehsan.com, not the full path), and sends nothing at all
when downgrading from HTTPS to HTTP.
Permissions-Policy: camera=(), microphone=(), geolocation=()
This declares which powerful browser features your site is allowed to use. The
empty parentheses () mean “no origin, not even me.” Since a static portfolio
never needs the camera, microphone, or location, we explicitly disable them. If a
bug or an injected script ever tried to prompt for them, the browser refuses
before the user is even asked.
Strict-Transport-Security (HSTS)
This is the big one. Strict-Transport-Security tells the browser: “for the next
max-age seconds, only ever talk to me over HTTPS — never plain HTTP.” Our value:
max-age=63072000— remember this for two years (in seconds).includeSubDomains— apply it to every subdomain too.preload— allow the domain to be baked into browsers’ built-in HSTS list, so even the very first visit is forced to HTTPS.
Once a browser has seen this header, it silently upgrades http:// to https://
on its own, which shuts the door on downgrade and man-in-the-middle attacks that
rely on that first insecure request.
flowchart LR
A[Browser request] --> B{source matches?}
B -->|/.* | C[Add 5 security headers]
B -->|/assets/.* | D[Add immutable cache]
B -->|.png .woff2 ...| E[Add immutable cache]The cache headers (applied to hashed assets)
Security is only half the file. The other half is performance. The trick is that
build tools put a content hash in the filename of every bundled asset — something
like index-a1b2c3.js. Because the filename changes whenever the content changes,
the old file is never “stale”: a different build produces a different name. That
means we can cache these files as aggressively as possible.
// vercel.json
{
"source": "/assets/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
}Breaking down Cache-Control:
public— any cache (the browser, and Vercel’s CDN) may store it.max-age=31536000— keep it for one year (in seconds) without re-checking.immutable— promise the file will never change, so the browser shouldn’t even send a revalidation request when you reload the page.
/assets/(.*) is where Vite emits its hashed bundles. I apply the same treatment
to media and fonts by extension, so an .woff2 font or a .png served from
anywhere gets the same year-long immutable cache:
// vercel.json
{
"source": "/(.*)\\.(avif|webp|png|jpg|jpeg|svg|woff2)$",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
}The \\.(...)$ pattern is a regex fragment matching the file extension at the end
of the path. The double backslash is just JSON escaping for a literal dot.
One caveat: never put immutable on your HTML. HTML files usually keep the same
URL (/, /about) while their contents change every deploy, so an immutable
cache would pin visitors to a stale page. Only fingerprinted filenames — the ones
whose name changes with their content — are safe to freeze.
A note on framework differences
The pattern is portable; only the asset folder name changes per framework. My Vite
portfolio caches /assets/(.*); my Astro blog caches /_astro/(.*) instead, and
adds a shorter one-day cache for its search index:
// blog/vercel.json
{
"source": "/pagefind/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=86400" }
]
}Same security block, different asset path. When you copy this into your own
project, check what folder your bundler outputs to and point the cache source at
that.
Verifying it worked
Deploy, then check the headers from your terminal:
curl -sI https://your-site.com | grep -i "strict-transport\|x-frame\|cache-control"You should see your HSTS and framing headers on the HTML response, and a
Cache-Control: public, max-age=31536000, immutable on any /assets/... URL.
What to remember
- One
vercel.jsonheadersarray hardens the whole site — no app code needed. - Put the five security headers on
"/(.*)"so they cover every response. - HSTS is the most important: it forces HTTPS for future visits;
preloadcovers even the first one. immutable, max-age=31536000is safe only for content-hashed filenames — never for HTML.- Match the cache
sourceto your bundler’s real output folder (/assetsfor Vite,/_astrofor Astro).
Related reading: headers are one layer of protection — for the endpoint itself, see adding in-memory rate limiting to a serverless function. This same Vercel project is what auto-deploys when the blog auto-generates posts with a GitHub Action.