HSTS is one of those headers that feels boring right up until it wrecks your local workflow.

I’ve seen this happen in teams more than once: someone enables Strict-Transport-Security in a staging or shared dev environment, tests with a real-looking hostname, and suddenly half the team can’t load the app over HTTP anymore. Then people start clearing browser data, changing ports, restarting Docker, and blaming the reverse proxy. The real problem is usually simpler: the browser is doing exactly what HSTS told it to do.

If you work on a developer-facing product or maintain local environments, you need to understand where HSTS helps, where it hurts, and how to avoid turning localhost into a support ticket.

Quick refresher: what HSTS actually does

HSTS tells the browser: “for this host, always use HTTPS for a while.”

Example:

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

Once the browser sees that header over a valid HTTPS connection, it remembers the rule. After that, even if you type http://example.test, the browser upgrades it to HTTPS before sending the request.

That’s the whole point. It prevents protocol downgrade attacks and helps kill off accidental HTTP access.

The catch is obvious once you say it out loud: if you set HSTS on a hostname you also use for local dev, you’ve told the browser to stop trusting your HTTP workflow.

Mistake #1: Sending HSTS from local development servers

This is the most common self-own.

A lot of apps set security headers globally. Someone adds HSTS middleware in Express, Rails, Django, Nginx, or Apache, and it gets applied everywhere: production, preview, staging, and local.

Example in Express:

app.use((req, res, next) => {
  res.setHeader(
    "Strict-Transport-Security",
    "max-age=31536000; includeSubDomains"
  );
  next();
});

Looks fine. Until your local app is reachable at https://myapp.localdev.test, the browser caches HSTS, and now http://myapp.localdev.test:3000 gets upgraded whether you want it or not.

Fix

Only send HSTS in production, and usually only at the edge.

if (process.env.NODE_ENV === "production") {
  app.use((req, res, next) => {
    res.setHeader(
      "Strict-Transport-Security",
      "max-age=31536000; includeSubDomains"
    );
    next();
  });
}

If you’re behind Nginx, put it there instead of inside every app:

server {
    listen 443 ssl;
    server_name example.com;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}

That keeps the policy where it belongs and makes environment-specific behavior easier to reason about.

If you want to sanity check what your server is actually returning, run a quick scan with Headertest.

Mistake #2: Assuming localhost behaves like every other host

localhost is special.

Browsers generally treat localhost as a secure context in ways that don’t apply to random local domains. But HSTS behavior can still get confusing when you mix:

  • localhost
  • 127.0.0.1
  • custom hosts like app.local.test
  • wildcard dev domains
  • reverse proxies and tunnels

A lot of developers think “it’s local, so the browser won’t care.” That’s not how it works. If the browser stores an HSTS policy for a hostname, it will enforce it.

localhost itself is often less painful than custom local domains, but the moment you use a realistic hostname, you’re back in normal browser policy territory.

Fix

Use localhost for simple local app work whenever possible.

If you need custom hostnames for cookies, subdomains, OAuth callbacks, or multi-tenant testing, treat them as if they were production-like domains:

  • use HTTPS intentionally
  • avoid accidental HSTS in non-prod environments
  • don’t reuse the same hostname across incompatible setups

This is one reason I prefer explicit local domains like app.dev.internal or tenant1.local.test only when there’s a real need. They add realism, but they also add browser state you now need to manage.

Mistake #3: Using includeSubDomains on shared dev domains

This one causes chaos fast.

Say your team uses dev.example.com, with apps under:

  • api.dev.example.com
  • admin.dev.example.com
  • alice.dev.example.com

Now someone enables:

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

on dev.example.com.

That tells the browser to force HTTPS for every subdomain too. If any subdomain isn’t properly serving HTTPS, it breaks. If someone expected one of those subdomains to stay HTTP-only for local testing, that’s over.

Fix

Be very careful with includeSubDomains outside production.

For production apex domains, includeSubDomains is often the right move. For shared development domains, it’s usually a bad idea unless every subdomain is consistently HTTPS and managed centrally.

A safer pattern is:

  • production: long max-age, likely includeSubDomains
  • staging/dev: no HSTS, or a tiny max-age if you absolutely need temporary testing

For example:

map $host $hsts_header {
    default "";
    example.com "max-age=31536000; includeSubDomains";
    www.example.com "max-age=31536000; includeSubDomains";
}

server {
    listen 443 ssl;
    add_header Strict-Transport-Security $hsts_header always;
}

That avoids accidentally applying a strict policy to every environment hanging off the same infrastructure.

Mistake #4: Trying to “turn off” HSTS by just removing the header

This catches people because browsers cache HSTS.

If you served:

Strict-Transport-Security: max-age=31536000

and later remove it, the browser does not forget immediately. It keeps enforcing the old policy until the timer expires.

That means your local or staging hostname can stay “stuck on HTTPS” long after you changed the config.

Fix

To clear HSTS for a host you control, serve this over HTTPS:

Strict-Transport-Security: max-age=0

That tells the browser to delete the cached policy for that host.

In app code:

app.get("/clear-hsts", (req, res) => {
  res.setHeader("Strict-Transport-Security", "max-age=0");
  res.send("HSTS cleared");
});

You wouldn’t keep a route like that around in production, but it’s useful for understanding the mechanism.

If you can’t reach the host over HTTPS anymore, you may need to clear the browser’s HSTS state manually for that hostname. Different browsers expose that differently, and the browser docs are the right source there. For Chromium-based browsers, check the official project docs and browser settings pages. For Firefox, use Mozilla’s official documentation.

Mistake #5: Reusing production-like domains for local work

I really dislike this pattern:

  • production: app.example.com
  • local via /etc/hosts: also app.example.com -> 127.0.0.1

It seems convenient. It also means your browser’s security state for the real app and your local app can collide.

If production has HSTS — and it should — your local mapping of that same hostname is now forced to HTTPS too. If your local server doesn’t have a valid cert for that host, things break exactly as designed.

Fix

Never reuse the real production hostname for localhost development.

Use separate local-only domains, such as:

  • app.localhost
  • app.local.test
  • app.internal.test

Then configure them intentionally.

For example, a local Nginx block:

server {
    listen 80;
    server_name app.local.test;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name app.local.test;

    ssl_certificate     /path/to/dev-cert.pem;
    ssl_certificate_key /path/to/dev-key.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

If your local domain needs HTTPS, do it properly with a trusted local development certificate setup. Don’t fight the browser and don’t train your team to click through certificate warnings.

Mistake #6: Forgetting that ports don’t save you

I’ve heard this one a lot: “The HSTS rule was for 443, but my local app is on 8080, so it should be fine.”

Nope.

HSTS applies to the host, not just one port. The browser upgrades the scheme to HTTPS for that hostname. After that, the port behavior follows normal URL rules or your explicit URL.

So if you visit:

http://app.local.test:8080

the browser may upgrade it to:

https://app.local.test:8080

If nothing is serving TLS on 8080, you get a failure that looks weird until you remember HSTS is in play.

Fix

If a hostname has HSTS, assume all HTTP access to that hostname is dead for the duration of the policy.

Use a different hostname for plain HTTP local services, or just serve HTTPS consistently on the ports you expect developers to use.

If you add:

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

you’re declaring intent for a much stricter world. Preloading is not a dev convenience feature. It’s a production commitment.

Even if you never submit the domain for preload inclusion, teams often cargo-cult the full header without understanding what that preload token means.

Fix

Don’t use preload on development or staging domains. Don’t copy-paste it by default.

If you’re evaluating preload for production, read the official browser and standards documentation first:

Use the smallest correct policy in each environment, not the fanciest-looking one.

A practical setup that doesn’t fight developers

This is the setup I usually recommend:

  • localhost for simple app development
  • separate local-only domains only when required
  • HTTPS locally if the feature actually depends on HTTPS
  • HSTS only in production
  • no includeSubDomains on shared dev domains
  • never map production hostnames to 127.0.0.1

And for app config, make the behavior explicit:

const isProd = process.env.NODE_ENV === "production";

app.use((req, res, next) => {
  if (isProd) {
    res.setHeader(
      "Strict-Transport-Security",
      "max-age=31536000; includeSubDomains"
    );
  }
  next();
});

That one conditional avoids a surprising amount of pain.

If your team is debugging weird HTTPS upgrades, redirect loops, or “why is my HTTP localhost URL suddenly TLS” behavior, inspect the response headers first. HSTS is often the hidden cause. A quick scan with Headertest can confirm whether the header is actually being sent.

HSTS is great. I want it on production sites. I do not want it leaking into local development by accident. That’s the line.