HSTS is one of those headers that’s boring right up until the day it saves you from a nasty downgrade or cookie theft issue.
If you’re running a Crystal app with Kemal, HSTS is usually easy to add. The hard part is deciding how aggressive to be. Short max-age? Long max-age? Include subdomains? Preload? Those choices have real operational consequences, especially if you run staging environments, legacy subdomains, or mixed infrastructure.
Here’s the practical comparison guide I’d want on my desk before flipping it on.
What HSTS does
Strict-Transport-Security tells browsers:
- only use HTTPS for this site
- automatically rewrite future HTTP requests to HTTPS
- refuse certificate bypasses in some cases once policy is cached
A typical header looks like this:
Strict-Transport-Security: max-age=31536000; includeSubDomains
A more aggressive version adds preload:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
HSTS only works when delivered over HTTPS. If a browser sees it on plain HTTP, it ignores it.
Why Kemal apps should care
Kemal is often used for lean apps, APIs, internal tools, and side projects that quickly become “production enough.” That’s exactly where HSTS gets skipped.
If your app has:
- login cookies
- session tokens
- admin panels
- API consumers using browsers
- any chance of users typing
http://
then HSTS is worth enabling.
If you want to sanity-check your headers after deployment, run a scan at HeaderTest.
The main HSTS strategies for Kemal
There isn’t really a “Kemal-specific HSTS standard library feature” you must use. In practice, you’ll usually set the header yourself in middleware. The comparison is about policy choices more than library choices.
Option 1: Minimal HSTS
This is the cautious starting point:
require "kemal"
before_all do |env|
env.response.headers["Strict-Transport-Security"] = "max-age=300"
end
Kemal.run
That gives you a 5-minute policy.
Pros
- very low risk
- easy to roll back
- good for first deployment validation
- lets you verify HTTPS is solid before committing browsers for months
Cons
- weak protection window
- doesn’t protect subdomains
- doesn’t qualify for preload
- easy to forget and leave in a half-configured state forever
My take
Good for a staged rollout, bad as a final answer. I’ve seen teams say “we enabled HSTS” when they really set max-age=300 six months ago and called it done.
Option 2: Standard production HSTS
This is the common production baseline:
require "kemal"
before_all do |env|
env.response.headers["Strict-Transport-Security"] = "max-age=31536000"
end
Kemal.run
That’s one year.
Pros
- strong protection for the main host
- simple to understand
- low operational complexity if you only care about one hostname
- aligns with common security guidance
Cons
- subdomains are not covered
- if you still expose any HTTP-only subdomain, users can still hit that separately
- rollback is slower because browsers cache policy
My take
If your app lives only at something like app.example.com and you’re not ready to make promises about every subdomain, this is a sane middle ground.
Option 3: HSTS with includeSubDomains
This is where things get serious:
require "kemal"
before_all do |env|
env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
end
Kemal.run
Pros
- protects the entire domain tree
- stops users from accidentally hitting insecure sibling subdomains
- better long-term security posture
- often the right move for mature infrastructure
Cons
- every current and future subdomain must support HTTPS correctly
- breaks forgotten legacy hosts fast
- staging and dev subdomains under the same parent can become painful
- certificate and DNS hygiene suddenly matter a lot more
My take
This is the option that catches teams lying to themselves. If you have old-admin.example.com, dev.example.com, or some mystery host from 2019, includeSubDomains can turn that into an outage. Great security control, but only if your subdomain inventory is real and current.
Option 4: HSTS preload-ready policy
The most aggressive common setup:
require "kemal"
before_all do |env|
env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
end
Kemal.run
Pros
- strongest browser-side HTTPS enforcement
- protects even before first visit once preloaded by browsers
- excellent for public-facing production domains with disciplined ops
- reduces first-request downgrade exposure
Cons
- hard to undo
- requires confidence in all subdomains
- mistakes can linger for a long time due to browser preload lists
- not a good fit for messy or fast-changing domain setups
My take
Preload is not a badge of honor. It’s a commitment. I like it for clean, stable domains with centralized control. I do not like it for startups with random subdomains, internal teams spinning up hosts, or environments where DNS governance is basically vibes.
Best way to add HSTS in Kemal
A simple before_all hook is usually enough. If you want to avoid setting it outside production, gate it with an environment variable.
require "kemal"
HSTS_HEADER = ENV["HSTS_HEADER"]? || ""
before_all do |env|
unless HSTS_HEADER.empty?
env.response.headers["Strict-Transport-Security"] = HSTS_HEADER
end
end
get "/" do
"Hello from Kemal"
end
Kemal.run
Example production value:
HSTS_HEADER="max-age=31536000; includeSubDomains"
This gives you a few nice properties:
- no HSTS in local HTTP development
- different policies for staging vs production
- easier rollout and rollback without code changes
Reverse proxy vs app-level header
A lot of Kemal apps sit behind Nginx, Caddy, HAProxy, or a cloud load balancer. In that setup, you can set HSTS at the proxy layer instead of in Crystal.
Set it in Kemal
Pros
- policy lives with app code
- easier to see in code review
- consistent across environments when infra is thin
- good for simple deployments
Cons
- every service must remember to set it
- duplicated config across apps
- weaker central governance
Set it at the reverse proxy
Pros
- one place to enforce policy
- easier for platform teams
- consistent across many apps
- often the better choice in larger deployments
Cons
- app developers may not realize it exists
- local environment behavior may differ
- debugging can be slightly less obvious
My take
If you run one or two Kemal services, app-level is fine. If you run a fleet, push it to the edge and standardize it there. Security headers are one of the few things that really benefit from central control.
Safe rollout plan
If you’re not sure whether your domain is clean, don’t jump straight to preload.
A sane progression looks like this:
- Start with
max-age=300 - Verify HTTPS redirects, certificates, and cookie settings
- Move to
max-age=86400 - Move to
max-age=31536000 - Add
includeSubDomainsonly after auditing subdomains - Consider
preloadonly if you fully control the namespace
That sequence is slower, but it avoids the classic “we broke some forgotten host and now browsers remember forever” problem.
Common mistakes with HSTS in Kemal setups
Sending HSTS over HTTP only
Does nothing. Browsers ignore it unless received via HTTPS.
Enabling it before HTTPS is fully correct
If redirects loop, certificates are broken, or mixed domain handling is sloppy, HSTS locks users into a bad experience.
Using includeSubDomains without inventory
This is the big one. If you don’t know all subdomains, you’re guessing.
Preloading too early
I’ve seen teams treat preload like a security trophy. It’s really an operational contract.
Assuming HSTS replaces redirects
It doesn’t. You still want proper HTTP-to-HTTPS redirects at the server or proxy layer.
Recommended setups
For a small Kemal app on one hostname
Use:
Strict-Transport-Security: max-age=31536000
For a mature production domain with full subdomain control
Use:
Strict-Transport-Security: max-age=31536000; includeSubDomains
For a highly controlled public domain ready for long-term commitment
Use:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
For first rollout or uncertain infrastructure
Use:
Strict-Transport-Security: max-age=300
Final recommendation
For most Crystal with Kemal deployments, I’d start with app-level HSTS or edge-level HSTS set to:
max-age=31536000
Then I’d move to:
max-age=31536000; includeSubDomains
only after checking every subdomain that matters.
I’d treat preload as an infrastructure decision, not an app decision.
If you want the practical default: use HSTS, keep it simple, and don’t overcommit your domain until you’ve audited the blast radius. That’s the difference between “secure by default” and “secure until someone remembers the old Jenkins box.”
For Kemal-specific behavior and middleware patterns, check the official docs: