HSTS looks simple: send one response header and browsers stop using HTTP for your site.

In practice, teams still get it wrong all the time, especially in Erlang systems sitting behind load balancers, reverse proxies, or mixed legacy setups. I’ve seen Cowboy apps ship with “secure” configs that quietly do nothing, break subdomains, or lock a bad decision into browsers for months.

If you’re running Cowboy, the tricky part usually isn’t the header syntax. It’s where you set it, when you set it, and whether your deployment actually matches what the browser thinks is happening.

Mistake #1: Setting HSTS on HTTP responses

This is the classic one.

HSTS only works when the browser receives the Strict-Transport-Security header over HTTPS. If you attach it to plain HTTP responses, browsers ignore it. No upgrade policy gets stored.

I still see handlers doing this:

init(Req0, State) ->
    Req = cowboy_req:set_resp_header(
        <<"strict-transport-security">>,
        <<"max-age=31536000">>,
        Req0
    ),
    {ok, Req, State}.

If that handler is reachable over both HTTP and HTTPS, half your traffic may be getting a useless header.

Fix

Only serve HSTS on HTTPS responses, and redirect HTTP to HTTPS separately.

A common Cowboy setup is to run one listener for HTTP that only redirects, and one HTTPS listener that serves the app and sets HSTS.

%% HTTP listener: redirect only
init_http(Req0, State) ->
    Host = cowboy_req:host(Req0),
    Path = cowboy_req:path(Req0),
    Qs = cowboy_req:qs(Req0),

    Location = case Qs of
        <<>> -> <<"https://", Host/binary, Path/binary>>;
        _    -> <<"https://", Host/binary, Path/binary, "?", Qs/binary>>
    end,

    Req = cowboy_req:reply(301, #{
        <<"location">> => Location
    }, Req0),
    {ok, Req, State}.

Then on the HTTPS side:

init_https(Req0, State) ->
    Req1 = cowboy_req:set_resp_header(
        <<"strict-transport-security">>,
        <<"max-age=31536000; includeSubDomains">>,
        Req0
    ),
    Req = cowboy_req:reply(200, #{}, <<"OK">>, Req1),
    {ok, Req, State}.

That split keeps the behavior obvious.

Mistake #2: Enabling includeSubDomains before checking every subdomain

includeSubDomains is where people hurt themselves.

Once a browser stores this policy, every subdomain must be HTTPS-only. That includes old admin panels, forgotten marketing hosts, internal tools exposed through DNS, and weird one-off endpoints nobody remembers until they break.

I’m opinionated here: don’t turn on includeSubDomains just because a checklist said so. Audit first.

Fix

Roll out in stages:

  1. Start with a short max-age
  2. Use HTTPS on the main host only
  3. Verify all subdomains support HTTPS correctly
  4. Add includeSubDomains
  5. Increase max-age

A cautious first header looks like this:

<<"max-age=300">>

That gives you five minutes to validate behavior. Then move to a day:

<<"max-age=86400">>

Then a month, then a year:

<<"max-age=31536000; includeSubDomains">>

If you want a quick sanity check on your live headers, HeaderTest is handy for a free security headers scan.

Mistake #3: Turning on preload without meeting preload requirements

Preload is not just “extra secure HSTS.” It’s a hard commitment.

If you add preload, you’re asking browsers to ship your domain in their built-in HTTPS-only lists. That can be great, but if you’re not ready, it becomes a pain to unwind.

To qualify, you generally need:

  • max-age of at least 31536000
  • includeSubDomains
  • preload
  • HTTPS on the apex domain and all subdomains

People often send this header:

<<"max-age=31536000; preload">>

That is not enough for preload and signals confusion about the policy.

Fix

Only send a preload-ready header when you actually mean it:

<<"max-age=31536000; includeSubDomains; preload">>

And only after you’ve confirmed all subdomains are permanently HTTPS-capable. Not “mostly.” Permanently.

If you have any doubt, skip preload.

Mistake #4: Setting HSTS in app code when the proxy should own it

Cowboy is often not the edge. Nginx, HAProxy, AWS ALB, Cloudflare, or some ingress controller may terminate TLS before traffic ever reaches your Erlang app.

If TLS ends at the proxy, that proxy is usually the right place to set HSTS. Doing it in Cowboy can still work, but it creates two common problems:

  • inconsistent headers across apps behind the same proxy
  • HSTS missing from redirects or error pages generated before Cowboy sees the request

I’ve debugged cases where the app added HSTS to successful responses, but 301 redirects from the load balancer didn’t include it. Browsers never learned the policy.

Fix

Set HSTS at the TLS termination layer when possible. If you keep it in Cowboy, be sure every HTTPS response path gets it.

If you need to do it in Cowboy, centralize it in middleware instead of repeating it in handlers.

A simple middleware example:

-module(hsts_middleware).
-behaviour(cowboy_middleware).

-export([execute/2]).

execute(Req0, Env) ->
    Req = cowboy_req:set_resp_header(
        <<"strict-transport-security">>,
        <<"max-age=31536000; includeSubDomains">>,
        Req0
    ),
    {ok, Req, Env}.

Then add it to your Cowboy middleware stack so handlers don’t have to remember it.

Mistake #5: Sending HSTS on localhost or non-production environments

Browsers cache HSTS aggressively. If you send it for local development domains, test hosts, or shared staging names, you can create really annoying debugging sessions.

You hit a dev URL once over HTTPS, the browser stores HSTS, and now every future request gets upgraded whether you want it or not.

Fix

Do not send HSTS outside production internet-facing domains.

Gate it by environment:

maybe_set_hsts(Req0) ->
    case application:get_env(myapp, env, dev) of
        prod ->
            cowboy_req:set_resp_header(
                <<"strict-transport-security">>,
                <<"max-age=31536000; includeSubDomains">>,
                Req0
            );
        _ ->
            Req0
    end.

This sounds obvious until somebody clones the production config into staging.

Mistake #6: Using a long max-age too early

A one-year HSTS policy sounds nice. It’s also a great way to make mistakes stick around.

If you’re just rolling out HTTPS, switching certificates, or migrating traffic patterns, don’t go straight to 31536000.

Fix

Treat HSTS like a deployment, not a toggle.

A safer progression:

  • max-age=300
  • max-age=86400
  • max-age=604800
  • max-age=31536000

That sequence gives you room to catch redirect loops, broken assets, bad subdomains, or certificate issues before browsers pin the rule for a year.

Mistake #7: Forgetting that HSTS doesn’t fix mixed content

I’ve heard people say “we enabled HSTS, so we’re good.” No. HSTS upgrades navigation to HTTPS for your domain. It does not magically clean up every insecure resource in your pages.

If your app still references http:// scripts, images, or third-party assets, you can still have mixed content problems.

Fix

Audit templates and generated URLs. In Cowboy apps, this often means checking whatever renders absolute links, asset URLs, and redirects.

Bad:

AssetUrl = <<"http://example.com/static/app.js">>.

Good:

AssetUrl = <<"https://example.com/static/app.js">>.

Or better, use relative URLs where appropriate so scheme mistakes are harder to introduce.

Mistake #8: Assuming HSTS works before the first secure visit

HSTS is not a solution to the very first HTTP request unless your domain is preloaded. On first contact, a user can still hit http://example.com and rely on your redirect.

That means your HTTP to HTTPS redirect still matters. A lot.

Fix

Keep the redirect fast and permanent.

Use 301 or 308, and don’t serve content on HTTP. Just redirect.

Req = cowboy_req:reply(308, #{
    <<"location">> => <<"https://example.com", Path/binary>>
}, Req0).

If you want first-visit protection, that’s where preload enters the picture. But again, only if you’re actually ready.

Mistake #9: Duplicating or conflicting HSTS headers

This happens when both the proxy and Cowboy add the header, sometimes with different values. Browser behavior isn’t something you want to leave to “probably fine” when security policy is involved.

Fix

Pick one layer as the source of truth.

If the edge proxy owns security headers, remove HSTS logic from Cowboy. If Cowboy owns it, make sure the proxy does not inject another version.

Check actual live responses, including:

  • normal 200 responses
  • redirects
  • 404s
  • 500s
  • static assets

That’s where config drift shows up.

A sane Cowboy HSTS baseline

If your whole domain tree is HTTPS-ready and Cowboy is responsible for headers, this is a reasonable production baseline:

set_security_headers(Req0) ->
    Req1 = cowboy_req:set_resp_header(
        <<"strict-transport-security">>,
        <<"max-age=31536000; includeSubDomains">>,
        Req0
    ),
    Req1.

If you’re still rolling out, use:

<<"max-age=86400">>

And if you plan to preload later, treat that as a separate project with a real subdomain audit.

HSTS is one of those controls that’s easy to add and easy to mess up. Cowboy doesn’t make it hard, but Erlang deployments often have enough moving parts that the header alone is the least interesting part of the problem. The real work is knowing your edge, your subdomains, and your rollout plan.