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 yearincludeSubDomains: apply the rule to all subdomains toopreload: asks browsers to include your domain in built-in preload lists
My default advice:
- Start with
max-age=300in staging if you’re testing - Move to
max-age=31536000in production once you’re sure - Only use
includeSubDomainsif every subdomain is HTTPS-only - Only use
preloadif 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:
- Redirect all HTTP traffic to HTTPS
- 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-ageat least 1 yearincludeSubDomainspreload
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.
Recommended configs
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.