HSTS is one of those headers that looks trivial until you ship it wrong.
For Koa apps, the mechanics are easy: send Strict-Transport-Security over HTTPS. The hard part is rollout, preload, proxies, subdomains, and not bricking a staging or legacy setup by accident.
This guide is the version I wish more teams had on hand: what to send, when to send it, and copy-paste Koa examples that won’t surprise you later.
What HSTS does
HSTS tells browsers:
- only use HTTPS for this site
- automatically rewrite future
http://requests tohttps:// - refuse invalid certificate bypasses for the protected host
- optionally apply the rule to all subdomains
Example header:
Strict-Transport-Security: max-age=31536000; includeSubDomains
That means:
max-age=31536000: remember this for 1 yearincludeSubDomains: apply the rule to all subdomains too
If you also add preload, you’re asking to be included in browser preload lists:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
That last step is not casual. Preload is a commitment.
The Koa one-liner
If your app is definitely serving HTTPS, the simplest Koa middleware is:
app.use(async (ctx, next) => {
await next();
if (ctx.secure) {
ctx.set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
}
});
A few rules here:
- send HSTS only on HTTPS responses
- don’t send it on plain HTTP
- place it high enough in the stack that most responses get it
I usually put it near the top, after any proxy trust config, before routes.
Minimal production-safe middleware
Here’s a cleaner reusable version:
function hsts(options = {}) {
const {
maxAge = 31536000,
includeSubDomains = true,
preload = false,
} = options;
let value = `max-age=${Math.floor(maxAge)}`;
if (includeSubDomains) value += '; includeSubDomains';
if (preload) value += '; preload';
return async (ctx, next) => {
await next();
if (ctx.secure) {
ctx.set('Strict-Transport-Security', value);
}
};
}
Use it like this:
const Koa = require('koa');
const app = new Koa();
app.proxy = true; // if you're behind a reverse proxy that sets X-Forwarded-Proto
app.use(hsts({
maxAge: 31536000,
includeSubDomains: true,
preload: false,
}));
app.use(async (ctx) => {
ctx.body = 'hello';
});
app.listen(3000);
If you’re behind Nginx, a load balancer, or a CDN
This is where people get burned.
Koa’s ctx.secure depends on whether Koa believes the original request was HTTPS. If TLS terminates at a proxy, your Node app may only see plain HTTP unless you trust proxy headers.
For Koa:
app.proxy = true;
That tells Koa to respect X-Forwarded-Proto and similar forwarded values from your proxy.
Then this works:
app.use(async (ctx, next) => {
await next();
if (ctx.secure) {
ctx.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
});
Without app.proxy = true, you may silently fail to send HSTS in production behind a proxy.
My rule: if HTTPS is terminated upstream, verify ctx.secure with a real request before assuming anything.
Redirect HTTP to HTTPS first
HSTS is not a replacement for redirects. It makes future visits safer, but the browser has to see the header over HTTPS before it can enforce it.
You still need an HTTP-to-HTTPS redirect.
Basic Koa redirect logic:
app.use(async (ctx, next) => {
if (!ctx.secure) {
const host = ctx.host;
ctx.status = 301;
ctx.redirect(`https://${host}${ctx.url}`);
return;
}
await next();
});
If you’re behind a proxy:
const Koa = require('koa');
const app = new Koa();
app.proxy = true;
app.use(async (ctx, next) => {
if (!ctx.secure) {
ctx.status = 301;
ctx.redirect(`https://${ctx.host}${ctx.url}`);
return;
}
await next();
});
app.use(async (ctx, next) => {
await next();
if (ctx.secure) {
ctx.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
});
That’s the standard pattern: redirect first, then serve HSTS on HTTPS.
Safe rollout strategy
Don’t jump straight to one year plus preload unless you already know your subdomains are clean.
A safer rollout looks like this:
Phase 1: short cache
Strict-Transport-Security: max-age=300
Five minutes. Good for testing.
Phase 2: one week
Strict-Transport-Security: max-age=604800
Now you can catch weird edge cases.
Phase 3: one year
Strict-Transport-Security: max-age=31536000; includeSubDomains
Only after you’re sure every relevant subdomain supports HTTPS properly.
Phase 4: consider preload
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Preload means all major browsers may hardcode your domain as HTTPS-only. That’s great when you’re ready. It’s painful when you’re not.
includeSubDomains can break more than you think
This directive applies HSTS to every subdomain, not just www.
That includes things like:
api.example.comadmin.example.comold.example.com- forgotten dev hosts
- mail-related web UIs
- random vendor integrations on a subdomain
If any of those are still HTTP-only, misconfigured, or using bad certs, includeSubDomains will hurt.
Same warning for preload: preload requires includeSubDomains, so you’re taking the whole tree along for the ride.
If your organization has messy DNS hygiene, audit first.
Localhost and development
Do not force HSTS on localhost during normal development.
Browsers can cache HSTS aggressively, and once they do, testing plain HTTP locally gets annoying fast.
A common pattern:
const isProduction = process.env.NODE_ENV === 'production';
app.use(async (ctx, next) => {
await next();
if (isProduction && ctx.secure) {
ctx.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
});
If you do local HTTPS testing, use a separate dev domain or a controlled setup rather than casually teaching your browser strict transport rules for hosts you use every day.
Disable HSTS correctly
To remove HSTS, send:
Strict-Transport-Security: max-age=0
Koa example:
app.use(async (ctx, next) => {
await next();
if (ctx.secure) {
ctx.set('Strict-Transport-Security', 'max-age=0');
}
});
A catch: browsers only see this if they can still connect over HTTPS. If you already broke certs or removed HTTPS entirely, users with cached HSTS are stuck.
That’s one more reason to treat long max-age values with respect.
Full copy-paste example
This is a practical Koa setup for apps behind a proxy:
const Koa = require('koa');
const app = new Koa();
app.proxy = true;
function hsts({
maxAge = 31536000,
includeSubDomains = true,
preload = false,
} = {}) {
let header = `max-age=${Math.floor(maxAge)}`;
if (includeSubDomains) header += '; includeSubDomains';
if (preload) header += '; preload';
return async (ctx, next) => {
await next();
if (ctx.secure) {
ctx.set('Strict-Transport-Security', header);
}
};
}
app.use(async (ctx, next) => {
if (!ctx.secure) {
ctx.status = 301;
ctx.redirect(`https://${ctx.host}${ctx.url}`);
return;
}
await next();
});
app.use(hsts({
maxAge: 31536000,
includeSubDomains: true,
preload: false,
}));
app.use(async (ctx) => {
ctx.body = {
ok: true,
secure: ctx.secure,
};
});
app.listen(3000, () => {
console.log('Server listening on :3000');
});
Helmet option for Koa
If you prefer a maintained middleware package instead of rolling your own, use the official Helmet docs for Koa-compatible integrations and header behavior:
const Koa = require('koa');
const helmet = require('helmet');
const app = new Koa();
app.proxy = true;
app.use(helmet.strictTransportSecurity({
maxAge: 31536000,
includeSubDomains: true,
preload: false,
}));
app.use(async (ctx) => {
ctx.body = 'ok';
});
Docs: Helmet documentation
I still like understanding the raw header even if I use middleware. HSTS is too consequential to treat as magic.
How to verify it
Check the response headers over HTTPS:
curl -I https://example.com
You want to see:
Strict-Transport-Security: max-age=31536000; includeSubDomains
If you’re testing behind a proxy or CDN, check the public edge, not just localhost.
You can also run a quick header scan with HeaderTest to confirm HSTS and the rest of your security headers are actually making it to users.
Common mistakes
Sending HSTS over HTTP
Browsers ignore HSTS sent over insecure HTTP. It has to be delivered over HTTPS.
Forgetting proxy trust
If TLS terminates upstream and app.proxy isn’t enabled, ctx.secure may be false and your header won’t be sent.
Enabling preload too early
Preload is hard to undo quickly. Don’t do it because it “sounds more secure.”
Using includeSubDomains without an inventory
This is the classic enterprise footgun.
Setting huge max-age on day one
Start short. Prove your setup. Then increase it.
Recommended defaults
For a modern production Koa app with full HTTPS across the domain:
Strict-Transport-Security: max-age=31536000; includeSubDomains
For cautious rollout:
Strict-Transport-Security: max-age=604800
For preload-ready setups only:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Official docs
If I had to give one opinionated rule: don’t treat HSTS as “just another header.” It’s a browser-side policy cache with a long memory. Roll it out like infrastructure, not like a CSS tweak.