HTTP Strict Transport Security, or HSTS, is one of those headers that’s boring right up until it saves you from a nasty downgrade attack.

If you run a Hono app over HTTPS, you should probably send it. The browser sees the header once over a secure connection, remembers it, and refuses to talk to your site over plain HTTP for the configured period. That cuts off a whole class of “strip HTTPS and hope the user doesn’t notice” nonsense.

For Hono, this is straightforward. The tricky part is getting the policy right and not bricking your local workflow or subdomains.

The header

A typical HSTS header looks like this:

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

What the directives mean:

  • max-age=31536000: remember HTTPS-only for 1 year
  • includeSubDomains: apply the rule to all subdomains too
  • preload: asks browsers to include your domain in built-in preload lists

My default advice:

  • Start with max-age=300 in staging if you’re testing
  • Move to max-age=31536000 in production once you’re sure
  • Only use includeSubDomains if every subdomain is HTTPS-only
  • Only use preload if you fully understand the commitment

Preload is not a casual checkbox. Once your domain is preloaded, backing out is annoying and slow.

Basic HSTS in Hono

If you just want to set the header on every response, a tiny middleware is enough.

import { Hono } from 'hono'

const app = new Hono()

app.use('*', async (c, next) => {
  await next()
  c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
})

app.get('/', (c) => {
  return c.text('Hello Hono')
})

export default app

That works, but I usually prefer setting security headers before I forget about them, and I want the policy to be environment-aware.

Production-safe HSTS middleware

This version avoids sending HSTS in local development and makes the value easy to control.

import { Hono } from 'hono'

const app = new Hono()

const isProd = process.env.NODE_ENV === 'production'

app.use('*', async (c, next) => {
  await next()

  if (!isProd) return

  c.header(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains'
  )
})

app.get('/', (c) => c.text('Secure enough to ship'))

export default app

Why skip it in dev?

Because browsers cache HSTS aggressively. If you set it on localhost through some weird HTTPS setup, you can create annoying redirect behavior and spend 20 minutes blaming Hono for something the browser remembered.

Better: only send HSTS on HTTPS requests

HSTS should only be delivered over HTTPS. Browsers ignore it over HTTP anyway, but I still like to guard it explicitly.

In Hono, the exact way you detect HTTPS depends on where you run it. On a platform behind a proxy or CDN, x-forwarded-proto is often what you want.

import { Hono } from 'hono'

const app = new Hono()

app.use('*', async (c, next) => {
  await next()

  const proto = c.req.header('x-forwarded-proto') || new URL(c.req.url).protocol.replace(':', '')
  const isHttps = proto === 'https'

  if (!isHttps) return

  c.header(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains'
  )
})

app.get('/', (c) => c.text('Hello over HTTPS'))

export default app

If you’re on Cloudflare, Fly.io, Render, Railway, or behind Nginx, check what headers your platform sets. I’ve seen people confidently check the wrong thing and then wonder why HSTS never shows up.

A reusable Hono middleware

If you want something cleaner, wrap it up.

import type { MiddlewareHandler } from 'hono'

type HstsOptions = {
  maxAge?: number
  includeSubDomains?: boolean
  preload?: boolean
  enabled?: boolean
}

export function hsts(options: HstsOptions = {}): MiddlewareHandler {
  const {
    maxAge = 31536000,
    includeSubDomains = true,
    preload = false,
    enabled = true,
  } = options

  const directives = [`max-age=${maxAge}`]

  if (includeSubDomains) directives.push('includeSubDomains')
  if (preload) directives.push('preload')

  const value = directives.join('; ')

  return async (c, next) => {
    await next()

    if (!enabled) return

    const proto =
      c.req.header('x-forwarded-proto') ||
      new URL(c.req.url).protocol.replace(':', '')

    if (proto !== 'https') return

    c.header('Strict-Transport-Security', value)
  }
}

Use it like this:

import { Hono } from 'hono'
import { hsts } from './middleware/hsts'

const app = new Hono()

app.use(
  '*',
  hsts({
    maxAge: 31536000,
    includeSubDomains: true,
    preload: false,
    enabled: process.env.NODE_ENV === 'production',
  })
)

app.get('/', (c) => c.text('HSTS enabled'))

export default app

That’s the version I’d actually keep in a real project.

HSTS with redirects from HTTP to HTTPS

A common setup is:

  1. Redirect all HTTP traffic to HTTPS
  2. Serve HSTS on HTTPS responses

That’s the right order of operations. HSTS does not replace redirects for first-time visitors. The browser has to learn the policy first unless your domain is preloaded.

A bare redirect in Hono looks like this:

import { Hono } from 'hono'

const app = new Hono()

app.use('*', async (c, next) => {
  const proto =
    c.req.header('x-forwarded-proto') ||
    new URL(c.req.url).protocol.replace(':', '')

  if (proto === 'http') {
    const url = new URL(c.req.url)
    url.protocol = 'https:'
    return c.redirect(url.toString(), 301)
  }

  await next()
})

app.use('*', async (c, next) => {
  await next()
  c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
})

app.get('/', (c) => c.text('HTTPS only'))

export default app

If your edge platform already forces HTTPS, don’t duplicate the redirect unless you know why you’re doing it. Two layers trying to “help” can get messy.

Preload: use with care

If you want preload eligibility, the header must be:

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

Requirements are strict for good reason:

  • valid HTTPS on the main domain
  • valid HTTPS on all subdomains
  • redirect HTTP to HTTPS
  • max-age at least 1 year
  • includeSubDomains
  • preload

Hono config:

app.use(
  '*',
  hsts({
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
    enabled: process.env.NODE_ENV === 'production',
  })
)

I only recommend preload for domains with mature infrastructure. If you’ve got mystery subdomains, old admin panels, forgotten MX helpers, or random customer-specific hostnames, slow down.

Don’t break local and preview environments

This is where people get sloppy.

Bad idea:

app.use('*', hsts({ maxAge: 31536000, includeSubDomains: true }))

Better:

const host = process.env.APP_ENV || 'development'

app.use(
  '*',
  hsts({
    maxAge: host === 'production' ? 31536000 : 300,
    includeSubDomains: host === 'production',
    preload: false,
    enabled: host === 'production',
  })
)

For preview deployments like my-app-git-feature.example-host.dev, be careful with includeSubDomains if your main domain policy could affect them. HSTS inheritance across subdomains is great when intentional and painful when not.

How to verify it

Use curl first. It’s fast and tells the truth.

curl -I https://yourdomain.com

You want to see:

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

If you’re behind a CDN or reverse proxy, test the real public URL, not just your local server.

You can also run a free security headers scan at HeaderTest to confirm HSTS and catch missing headers nearby.

Common mistakes

Sending HSTS over HTTP only

Browsers ignore it. You need HTTPS responses.

Using includeSubDomains too early

If blog.example.com is secure but old-admin.example.com is not, you’ve just created an outage for users who previously visited the main site.

Setting a huge max-age on day one

Start smaller if you’re unsure:

app.use('*', hsts({ maxAge: 86400, enabled: true }))

That’s one day. Safer for rollout.

Assuming preload is reversible

It is, eventually. Not quickly. Treat it like a one-way door.

Forgetting the proxy layer

If TLS terminates at a load balancer, your Hono app may think the request is HTTP unless you trust the forwarded headers your platform provides.

Conservative production config

app.use(
  '*',
  hsts({
    maxAge: 31536000,
    includeSubDomains: false,
    preload: false,
    enabled: process.env.NODE_ENV === 'production',
  })
)

Good for teams still auditing subdomains.

Strong production config

app.use(
  '*',
  hsts({
    maxAge: 31536000,
    includeSubDomains: true,
    preload: false,
    enabled: process.env.NODE_ENV === 'production',
  })
)

Good default if all subdomains are HTTPS-only.

Preload-ready config

app.use(
  '*',
  hsts({
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true,
    enabled: process.env.NODE_ENV === 'production',
  })
)

Only for domains you fully control.

My practical take

For most Hono apps, I’d ship this:

app.use(
  '*',
  hsts({
    maxAge: 31536000,
    includeSubDomains: true,
    preload: false,
    enabled: process.env.NODE_ENV === 'production',
  })
)

Then I’d verify HTTPS redirects at the platform edge, test every important subdomain, and leave preload alone until the setup has been boring for a while.

That’s really the whole game with HSTS: easy header, serious consequences. The implementation in Hono is tiny. The decision-making around rollout is the part that deserves respect.