HSTS looks simple: send one header, force HTTPS, move on.
That’s exactly why people mess it up.
With AdonisJS, the mistakes usually aren’t about syntax. They’re about where the app sits in production, how TLS is terminated, whether subdomains are ready, and whether you’ve accidentally made local development annoying for everyone on the team.
Here are the HSTS mistakes I see most often in AdonisJS apps, plus the fixes that actually work.
First: what HSTS should look like
The header is usually this:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Or if you’re going all-in on preload:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
A year (31536000) is the normal production value. If you’re still testing, start lower.
In AdonisJS, you’ll usually set this either:
- in app middleware
- at the reverse proxy or load balancer
- or both, though I prefer one clear source of truth
A simple middleware example:
// start/kernel.ts
import server from '@adonisjs/core/services/server'
server.use([
() => import('#middleware/hsts_middleware'),
])
// app/middleware/hsts_middleware.ts
import type { HttpContext } from '@adonisjs/core/http'
export default class HstsMiddleware {
async handle({ request, response }: HttpContext, next: () => Promise<void>) {
await next()
if (request.secure()) {
response.header(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
)
}
}
}
That request.secure() check matters more than people think.
Mistake 1: Sending HSTS on HTTP responses
This is probably the most common mistake.
If your app sends Strict-Transport-Security over plain HTTP, browsers ignore it. HSTS is only honored when delivered over HTTPS.
A lot of developers add the header globally and assume they’re done:
response.header(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
)
That’s sloppy. If your app is behind a proxy and AdonisJS doesn’t correctly detect HTTPS, you may think HSTS is active when it isn’t.
Fix
Only set HSTS when the request is actually secure:
import type { HttpContext } from '@adonisjs/core/http'
export default class HstsMiddleware {
async handle({ request, response }: HttpContext, next: () => Promise<void>) {
await next()
if (!request.secure()) {
return
}
response.header(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
)
}
}
Then verify the live response with your browser dev tools or a header scanner like Headertest.
Mistake 2: Forgetting that your reverse proxy terminates TLS
This one bites AdonisJS deployments behind Nginx, Caddy, Traefik, Cloud load balancers, and container platforms.
The browser connects via HTTPS to the proxy, but the proxy forwards traffic to AdonisJS over HTTP. If AdonisJS doesn’t trust the proxy headers, request.secure() may return false.
That means:
- redirects may be wrong
- secure cookies may break
- HSTS may never be set
Fix
Make sure AdonisJS is configured to trust your proxy setup so forwarded protocol headers are respected.
Your exact setup depends on your AdonisJS version and deployment model, but the core idea is the same: trust X-Forwarded-Proto only from infrastructure you control.
Then test this route:
// start/routes.ts
import router from '@adonisjs/core/services/router'
router.get('/debug-request', async ({ request }) => {
return {
secure: request.secure(),
protocol: request.protocol(),
url: request.completeUrl(),
}
})
If you hit that route through your public HTTPS URL and secure is false, your proxy config is wrong.
I’ve seen teams waste hours debugging HSTS when the real problem was “the app thinks everything is HTTP.”
Mistake 3: Enabling includeSubDomains before your subdomains are ready
includeSubDomains is great. It’s also the fastest way to break an old admin panel, forgotten staging host, or random customer vanity subdomain.
Once a browser sees:
Strict-Transport-Security: max-age=31536000; includeSubDomains
it will force HTTPS for every subdomain under that parent domain for the duration of max-age.
That includes things like:
api.example.comadmin.example.comold.example.comstaging.example.com
If even one of them doesn’t support HTTPS correctly, users will hit hard failures.
Fix
Audit subdomains before enabling includeSubDomains.
A safer rollout:
- Start with the apex domain only
- Use a short
max-age - Confirm every required subdomain serves valid HTTPS
- Then add
includeSubDomains - Only consider
preloadafter everything is boring and stable
Example staged rollout:
response.header('Strict-Transport-Security', 'max-age=300')
Then later:
response.header(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
)
Boring is good here. HSTS is not where you want surprises.
Mistake 4: Jumping straight to preload
A lot of people see preload and think “extra secure, why not.”
Because preload is sticky, that’s why.
If you submit a domain for HSTS preload, browsers can hardcode HTTPS behavior before the user even visits your site. That’s powerful, but it’s also operationally unforgiving.
Preload requires:
max-ageof at least 1 yearincludeSubDomainspreload- valid HTTPS across the whole domain tree you care about
Fix
Don’t use preload until you’re sure you can support it long-term.
If you do want it, the header looks like this:
response.header(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload'
)
I’d treat preload like a deployment contract, not a security checkbox.
Mistake 5: Setting a huge max-age on day one
Technically, a year is normal. Operationally, a year is a long time if you got something wrong.
If this is your first HSTS rollout and you immediately ship:
Strict-Transport-Security: max-age=31536000; includeSubDomains
you’ve removed your margin for error.
Fix
Ramp up in stages.
A practical rollout:
// Phase 1: 5 minutes
response.header('Strict-Transport-Security', 'max-age=300')
// Phase 2: 1 day
response.header('Strict-Transport-Security', 'max-age=86400')
// Phase 3: 1 year
response.header(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
)
That gives you time to catch weird subdomain or proxy issues before they become support tickets.
Mistake 6: Enabling HSTS in local development
Don’t do this to your team.
If you serve a local domain over HTTPS with HSTS and later need to access it over HTTP, browsers may keep forcing HTTPS. That leads to confusing “why is localhost broken?” debugging.
HSTS is a production policy. Treat it like one.
Fix
Gate the header by environment.
import env from '#start/env'
import type { HttpContext } from '@adonisjs/core/http'
export default class HstsMiddleware {
async handle({ request, response }: HttpContext, next: () => Promise<void>) {
await next()
if (env.get('NODE_ENV') !== 'production') {
return
}
if (!request.secure()) {
return
}
response.header(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
)
}
}
That keeps local and preview environments sane.
Mistake 7: Assuming redirects replace HSTS
I still see apps that do a clean HTTP-to-HTTPS redirect and stop there.
Redirects help first contact. HSTS helps every visit after that.
Without HSTS, the browser may still begin with an HTTP request next time. That leaves room for downgrade attacks and sloppy network behavior.
Fix
Use both:
- redirect HTTP to HTTPS
- send HSTS on HTTPS responses
A simple route-level redirect isn’t enough. You want this enforced consistently at the edge or in global middleware.
Mistake 8: Setting HSTS in multiple layers with conflicting values
Maybe Nginx sets:
Strict-Transport-Security: max-age=31536000
And AdonisJS sets:
Strict-Transport-Security: max-age=300; includeSubDomains
Now you’ve created ambiguity and future confusion. Depending on the setup, one may overwrite the other, or duplicate headers may appear.
Fix
Pick one layer as the source of truth.
My preference:
- set HSTS at the reverse proxy if every app behind it should have the same policy
- set HSTS in AdonisJS if policy differs per app or environment
Just don’t split ownership unless you like debugging config drift.
Mistake 9: Forgetting API and asset subdomains
Teams usually think about the main app and maybe www. Then six months later they realize:
- API clients hit
api.example.com - assets load from
cdn.example.com - uploads come from
files.example.com
If you use includeSubDomains, those hosts need proper HTTPS too. If you don’t use it, you may still want HSTS on those hosts individually.
Fix
Inventory every public hostname. Not just the “website.”
This is where a header scan and a manual host list help. Check each production hostname and confirm:
- valid certificate
- HTTPS redirect behavior
- HSTS presence
- no mixed content dependencies
A clean AdonisJS HSTS middleware example
Here’s a version I’d actually ship:
// app/middleware/hsts_middleware.ts
import env from '#start/env'
import type { HttpContext } from '@adonisjs/core/http'
export default class HstsMiddleware {
async handle({ request, response }: HttpContext, next: () => Promise<void>) {
await next()
if (env.get('NODE_ENV') !== 'production') {
return
}
if (!request.secure()) {
return
}
response.header(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
)
}
}
And register it globally so it applies consistently.
If you’re unsure whether it’s live, inspect the response headers directly or run a scan with Headertest.
The practical rule
HSTS is easy to enable and easy to get wrong.
For AdonisJS, the real checklist is:
- app correctly detects HTTPS behind proxies
- HSTS only sent on secure production traffic
- subdomains audited before
includeSubDomains - preload treated as permanent-ish
- rollout staged with sane
max-agevalues - one config owner, not three
That’s the difference between “we added the header” and “we deployed HSTS without breaking anything.”
If you want the framework details for your exact version, check the official AdonisJS documentation for middleware, request handling, and proxy configuration.