HTTP Strict Transport Security sounds boring right up until you need it.
HSTS is the header that tells browsers: “stop trying plain HTTP for this site, always use HTTPS.” That shuts down protocol downgrade attacks and removes a whole class of accidental insecure requests. If your site is on Cloudflare Pages, adding HSTS is easy. Adding it safely is the part people mess up.
I’ve seen teams copy-paste max-age=31536000; includeSubDomains; preload into production without checking whether every subdomain actually supports HTTPS. That’s how you brick old marketing hosts and weird internal tools.
Here’s how to do it properly with Cloudflare Pages Functions.
What HSTS does
The header looks like this:
Strict-Transport-Security: max-age=31536000; includeSubDomains
A browser that receives it over HTTPS will remember the rule for max-age seconds. After that:
- it upgrades future HTTP requests to HTTPS before sending them
- it refuses certificate exceptions in many cases
- it protects users from SSL stripping on later visits
A few practical details matter:
- Browsers ignore HSTS sent over plain HTTP.
- A user’s first ever visit is still vulnerable unless the domain is preloaded.
includeSubDomainsapplies the policy to every subdomain.preloadis a signal that you want the domain included in browser preload lists, but you still need to meet preload requirements and submit it.
If you want a quick way to verify your final headers, run a free security headers scan at headertest.com.
HSTS on Cloudflare Pages Functions
Pages Functions give you a clean place to modify responses at the edge. That means you can attach HSTS to:
- static Pages assets
- SSR responses
- API responses from Functions
The simplest pattern is to intercept the response and set the header centrally.
Basic _middleware.js setup
Create this file:
// functions/_middleware.js
export async function onRequest(context) {
const response = await context.next();
const newResponse = new Response(response.body, response);
newResponse.headers.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains"
);
return newResponse;
}
That applies HSTS to every request handled through Pages Functions middleware.
If you prefer TypeScript:
// functions/_middleware.ts
export const onRequest: PagesFunction = async (context) => {
const response = await context.next();
const newResponse = new Response(response.body, response);
newResponse.headers.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains"
);
return newResponse;
};
That’s enough for many sites, but I wouldn’t jump straight to a one-year policy on day one unless I knew the environment was clean.
A safer rollout strategy
HSTS is sticky. Once a browser caches it, you can’t easily take it back for users who already saw it. That’s why gradual rollout is the sane approach.
Start with a short max-age:
export async function onRequest(context) {
const response = await context.next();
const headers = new Headers(response.headers);
headers.set("Strict-Transport-Security", "max-age=300");
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
A rollout I like:
max-age=300for testingmax-age=86400for a daymax-age=2592000for a monthmax-age=31536000; includeSubDomainswhen you’re confident- add
preloadonly after you’ve verified preload requirements
That sequence gives you room to catch mistakes before they become painful.
Production-ready middleware
Here’s a version I’d actually ship. It skips local development, only sets HSTS on HTTPS, and gives you one place to tune the policy.
// functions/_middleware.ts
const HSTS_VALUE = "max-age=31536000; includeSubDomains";
export const onRequest: PagesFunction = async (context) => {
const request = context.request;
const url = new URL(request.url);
const response = await context.next();
const headers = new Headers(response.headers);
const isLocalhost =
url.hostname === "localhost" || url.hostname === "127.0.0.1";
const proto =
request.headers.get("x-forwarded-proto") || url.protocol.replace(":", "");
if (!isLocalhost && proto === "https") {
headers.set("Strict-Transport-Security", HSTS_VALUE);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
};
Cloudflare terminates TLS before your Function runs, so checking the forwarded protocol is a reasonable guard.
Adding preload
If you want preload, your header must look like this:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
And here’s the middleware version:
export async function onRequest(context) {
const response = await context.next();
const headers = new Headers(response.headers);
headers.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload"
);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
My opinion: don’t add preload because it feels “more secure.” Add it only when you fully control the domain and all subdomains, and you’re ready for a long-lived commitment. Preload removal is slow and annoying.
Common mistakes on Cloudflare Pages
1. Setting HSTS on preview deployments
Pages preview URLs are often under *.pages.dev. You might not want aggressive HSTS behavior there, especially if you’re testing multiple environments.
You can skip HSTS for preview hosts:
export async function onRequest(context) {
const request = context.request;
const url = new URL(request.url);
const response = await context.next();
const headers = new Headers(response.headers);
const isPreview = url.hostname.endsWith(".pages.dev");
if (!isPreview) {
headers.set(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains"
);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
2. Using includeSubDomains too early
This is the classic foot-gun.
If you have:
blog.example.comold.example.comstatus.example.com- random legacy DNS entries nobody remembers
includeSubDomains covers all of them. Every one needs valid HTTPS, consistently.
If you’re not sure, start with:
Strict-Transport-Security: max-age=31536000
Then upgrade later.
3. Trying to test HSTS casually in your own browser
Browsers cache HSTS aggressively. If you test a bad policy once, future behavior can look weird until you clear the HSTS state.
For Chrome-based browsers, you can inspect and clear HSTS entries via internal net tools. For Firefox, use its own certificate and site state controls. Don’t waste an hour debugging a cached browser policy when the server config is already fixed.
4. Sending HSTS from HTTP
Browsers ignore it over insecure transport. If you’re still relying on HTTP listeners somewhere, HSTS won’t rescue the first request. Redirect HTTP to HTTPS, then send HSTS on the HTTPS response.
Pair HSTS with redirects
HSTS is not a replacement for redirects. You still want HTTP to 301 or 308 to HTTPS.
On Cloudflare, this is often handled at the platform level or via routing rules. Your ideal setup is:
- HTTP requests redirect to HTTPS
- HTTPS responses include HSTS
- all canonical URLs are HTTPS-only
Verifying the header
After deployment, check it with curl:
curl -I https://example.com
You want to see something like:
HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains
Also test a few paths, not just /:
curl -I https://example.com/
curl -I https://example.com/login
curl -I https://example.com/api/health
Middleware usually covers everything, but I still verify the weird routes. Experience has taught me not to trust “global” config until I’ve checked the ugly corners.
Recommended configs
Conservative
Good for first rollout:
Strict-Transport-Security: max-age=86400
Standard
Good for mature HTTPS-only sites:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Strictest
Only if you know exactly what you’re doing:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Final Pages Functions example
If you want one clean snippet to drop in, use this:
// functions/_middleware.ts
const PROD_HSTS = "max-age=31536000; includeSubDomains";
export const onRequest: PagesFunction = async (context) => {
const request = context.request;
const url = new URL(request.url);
const response = await context.next();
const headers = new Headers(response.headers);
const host = url.hostname;
const proto =
request.headers.get("x-forwarded-proto") || url.protocol.replace(":", "");
const shouldSetHsts =
proto === "https" &&
host !== "localhost" &&
host !== "127.0.0.1" &&
!host.endsWith(".pages.dev");
if (shouldSetHsts) {
headers.set("Strict-Transport-Security", PROD_HSTS);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
};
That’s a sensible default for a real deployment: production hosts get HSTS, local and preview environments don’t.
HSTS is one of those headers that gives you a lot of value for very little code. The trick is respecting the blast radius. Start short, verify every hostname you care about, then crank it up. That’s the boring, correct way to do it — and in web security, boring usually wins.