HSTS looks simple: send one response header and browsers stop talking to your site over plain HTTP.

The catch is that HSTS is one of those headers that can absolutely improve security and absolutely break things if you roll it out carelessly. I’ve seen teams flip on includeSubDomains in production and then spend the afternoon finding forgotten subdomains, old staging boxes, and random vendor callbacks still using HTTP.

If you’re running Fastify, you’ve got a few ways to handle HSTS:

  1. Set the header manually
  2. Use @fastify/helmet
  3. Terminate TLS at a proxy/CDN and set HSTS there instead
  4. Combine app-level and edge-level controls carefully

Here’s how they compare, where each works well, and where they can bite you.

What HSTS actually does

HSTS stands for HTTP Strict Transport Security. The browser sees this header over HTTPS:

Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

After that, the browser will:

  • rewrite future http:// requests to https://
  • refuse invalid certificate click-throughs for that host
  • optionally apply the rule to subdomains
  • optionally treat the domain as preload-eligible

The official spec and browser behavior are covered in the HTTP docs from MDN and browser vendors, but the practical rule is simple:

Only send HSTS once you are sure HTTPS is working everywhere you claim it is.

Option 1: Set HSTS manually in Fastify

This is the most direct approach. No plugin, no abstraction.

import Fastify from 'fastify'

const fastify = Fastify()

fastify.addHook('onSend', async (request, reply, payload) => {
  if (request.protocol === 'https') {
    reply.header(
      'Strict-Transport-Security',
      'max-age=31536000; includeSubDomains'
    )
  }
  return payload
})

fastify.get('/', async () => {
  return { ok: true }
})

await fastify.listen({ port: 3000 })

Pros

  • Full control over the exact header value
  • No extra dependency if you only care about HSTS
  • Easy to gate by environment, host, or route

Cons

  • Easy to get subtly wrong
  • You have to remember HTTPS awareness behind proxies
  • You miss the convenience of managing other security headers in one place

The biggest footgun here is request.protocol. If Fastify is behind Nginx, a load balancer, or a platform ingress, your Node process may only see plain HTTP from the proxy unless you configure trust proxy correctly.

For example:

const fastify = Fastify({
  trustProxy: true
})

Without that, your app may think requests are HTTP and skip HSTS, or worse, behave inconsistently.

If you want manual control, I’d keep the logic explicit and environment-aware:

import Fastify from 'fastify'

const fastify = Fastify({ trustProxy: true })

const isProduction = process.env.NODE_ENV === 'production'
const hstsValue = 'max-age=86400' // start small

fastify.addHook('onSend', async (request, reply, payload) => {
  if (isProduction && request.protocol === 'https') {
    reply.header('Strict-Transport-Security', hstsValue)
  }
  return payload
})

Starting with 86400 seconds instead of a full year is usually the smart move. One day is forgiving. One year is a commitment.

Option 2: Use @fastify/helmet

For most Fastify apps, this is the sane default.

Fastify’s official Helmet integration lets you manage HSTS as part of a broader security-header policy.

import Fastify from 'fastify'
import helmet from '@fastify/helmet'

const fastify = Fastify({ trustProxy: true })

await fastify.register(helmet, {
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: false
  }
})

fastify.get('/', async () => {
  return { ok: true }
})

await fastify.listen({ port: 3000 })

Official docs:

Pros

  • Less boilerplate
  • Easy to manage HSTS alongside CSP, frame protections, and more
  • Good default path for teams that want consistency
  • Clear config structure

Cons

  • Slightly more abstraction than manual headers
  • You still need to understand HSTS rollout yourself
  • Easy for teams to cargo-cult config they don’t fully understand

I like this option because it makes the security policy visible in one place. If I open a Fastify app and see Helmet registered with explicit HSTS settings, I know roughly what the app is trying to do.

That said, Helmet won’t save you from bad decisions. If you set this:

hsts: {
  maxAge: 63072000,
  includeSubDomains: true,
  preload: true
}

you’re telling browsers, “force HTTPS across my whole domain tree for two years, and I want preload eligibility.” That’s not a casual setting. That’s a “we audited every subdomain and we mean it” setting.

Option 3: Set HSTS at the reverse proxy or CDN

A lot of production Fastify deployments sit behind something else:

  • Nginx
  • HAProxy
  • cloud load balancer
  • CDN
  • ingress controller

In that setup, setting HSTS at the edge often makes more sense than setting it in Node.

Pros

  • One place to enforce policy across multiple apps
  • Works even if some services aren’t Node/Fastify
  • Keeps transport/security concerns closer to TLS termination
  • Often easier to audit in infra config

Cons

  • App developers may not realize HSTS is enabled
  • Can drift from app expectations in local/staging environments
  • Harder to do per-route logic if you need exceptions

If TLS terminates before your Fastify app, edge-level HSTS is usually cleaner. The machine actually handling HTTPS is the one declaring “always use HTTPS.”

I still like documenting it in the app repo, even if the header is injected elsewhere. Otherwise, six months later, somebody sees no HSTS code in Fastify and assumes it’s missing.

If you’re not sure whether your site is returning the header correctly, run a scan with HeaderTest. It’s a fast way to verify what browsers actually receive.

Option 4: Hybrid setup

This is common in larger systems:

  • edge sets the canonical HSTS header
  • Fastify may set it in non-production or fallback environments
  • app code avoids overriding edge behavior in production

Example:

import Fastify from 'fastify'
import helmet from '@fastify/helmet'

const fastify = Fastify({ trustProxy: true })
const isProduction = process.env.NODE_ENV === 'production'

if (!isProduction) {
  await fastify.register(helmet, {
    hsts: {
      maxAge: 86400
    }
  })
}

Pros

  • Flexible across environments
  • Lets infra own production policy
  • Gives app teams realistic behavior in staging

Cons

  • More moving parts
  • Easier to create inconsistent headers between environments
  • Requires clear ownership

Hybrid setups work, but only if someone owns the policy. “Everyone kind of assumes someone else handles it” is how HSTS gets misconfigured.

Comparing the main approaches

Manual header in Fastify

Best for:

  • small apps
  • teams that want zero magic
  • very custom behavior

Avoid if:

  • you’re already using Helmet
  • you have multiple services and want centralized policy
  • your team tends to duplicate config inconsistently

@fastify/helmet

Best for:

  • most Fastify apps
  • teams that want a standard security-header setup
  • apps where security config should live with app code

Avoid if:

  • edge infrastructure already owns all security headers
  • you need very unusual header behavior and want total control

Proxy/CDN/edge config

Best for:

  • multi-service platforms
  • organizations with infra-managed TLS
  • consistent policy across stacks

Avoid if:

  • developers have no visibility into infra config
  • local and staging behavior matters and isn’t mirrored well

Safe rollout strategy

This matters more than the exact implementation.

Phase 1: get HTTPS solid

Before HSTS, make sure:

  • every production page works on HTTPS
  • redirects from HTTP to HTTPS are correct
  • certificates are valid everywhere
  • no required subdomain still depends on HTTP

Phase 2: start with a short max-age

Use something like:

Strict-Transport-Security: max-age=86400

That gives you one day of browser memory. If something goes wrong, recovery is annoying but manageable.

Phase 3: increase max-age

Once you’re confident, move to something like:

Strict-Transport-Security: max-age=31536000

One year is common and reasonable.

Phase 4: consider includeSubDomains

Only add this after auditing all subdomains you care about.

Strict-Transport-Security: max-age=31536000; includeSubDomains

This is where teams usually get burned.

Phase 5: think very hard before preload

Preload is not just another flag. It’s asking browsers to hardcode HTTPS expectations for your domain family.

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload

I treat preload as an infrastructure decision, not an app toggle.

My opinionated recommendation

For most Fastify teams:

  • use @fastify/helmet
  • enable trustProxy if you’re behind a proxy
  • start with maxAge: 86400
  • wait before adding includeSubDomains
  • leave preload alone until you’ve done a real audit

That gives you a clean, readable setup without pretending HSTS is just a checkbox.

A good production baseline looks like this:

import Fastify from 'fastify'
import helmet from '@fastify/helmet'

const fastify = Fastify({ trustProxy: true })

await fastify.register(helmet, {
  hsts: process.env.NODE_ENV === 'production'
    ? {
        maxAge: 86400
      }
    : false
})

fastify.get('/', async () => {
  return { status: 'ok' }
})

await fastify.listen({ port: 3000 })

Then later:

hsts: {
  maxAge: 31536000,
  includeSubDomains: true
}

If your platform already injects HSTS at the edge, I’d usually keep it there and document it clearly rather than duplicating it in Fastify.

HSTS is worth doing. It closes off downgrade paths and makes HTTPS enforcement stick in the browser. But it’s one of those controls where careful rollout beats aggressive config every time. The best HSTS setup is the one you can actually support across your real domain, not the one that looks toughest in a code snippet.