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 to https://
  • 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 year
  • includeSubDomains: 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.com
  • admin.example.com
  • old.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.

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.