I’ve seen this pattern more than once: a team moves fast, sets up HTTPS, gets the padlock in the browser, and assumes transport security is done. Then somebody runs a header scan and finds the obvious gap: no HSTS.

That was the situation with a small Bun-powered app I helped review. The site served authenticated pages over HTTPS, redirected HTTP to HTTPS, and generally looked fine at a glance. But the first request was still vulnerable. A user typing example.com or following an old http:// bookmark could be intercepted before the redirect ever helped.

That’s the whole reason HSTS exists.

For a Bun server, the fix is simple. The hard part is rolling it out without breaking subdomains, staging environments, or old assumptions in your infrastructure.

The real-world setup

The app was a Bun server using Bun.serve(). Pretty standard:

  • HTTPS was terminated at the edge in production
  • The app still listened on plain HTTP internally
  • The edge redirected http:// to https://
  • No Strict-Transport-Security header was present
  • A couple of old subdomains still existed and one didn’t support HTTPS correctly

The team thought they were covered because every HTTP request got a 301 redirect. That’s better than nothing, but it’s not HSTS.

Without HSTS, the first request can still be downgraded or tampered with by an active network attacker. Coffee shop Wi‑Fi, hostile proxy, bad ISP box — the old classics still matter.

Before: redirect-only, no HSTS

Here’s a simplified version of what they had in Bun:

import { serve } from "bun";

serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);
    const host = req.headers.get("host") ?? "localhost";

    if (req.headers.get("x-forwarded-proto") === "http") {
      return new Response(null, {
        status: 301,
        headers: {
          Location: `https://${host}${url.pathname}${url.search}`,
        },
      });
    }

    return new Response("Hello from Bun", {
      headers: {
        "Content-Type": "text/plain; charset=utf-8",
      },
    });
  },
});

This is common and incomplete.

A scanner would show something like:

  • Location: https://example.com/...
  • missing Strict-Transport-Security

You can verify your current headers with a free scan at headertest.com?utm_source=hsts-guide&utm_medium=blog&utm_campaign=article-link.

Why the redirect wasn’t enough

The problem is timing.

If the browser has never seen your site before, it does not magically know it should refuse HTTP. It tries HTTP first if the user or link says HTTP. Only after receiving your redirect does it switch to HTTPS.

That window is enough for an attacker to interfere.

HSTS changes browser behavior after the first secure response. Once a browser sees:

Strict-Transport-Security: max-age=31536000

it remembers: “for the next year, never use HTTP for this host.”

That means future requests get upgraded in the browser before the network request goes out.

The first fix: add HSTS on HTTPS responses only

This is the part people mess up: HSTS should be sent on HTTPS responses, not HTTP ones. Browsers ignore HSTS over insecure transport for obvious reasons.

Here’s the improved Bun version:

import { serve } from "bun";

const HSTS_VALUE = "max-age=300";

serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);
    const host = req.headers.get("host") ?? "localhost";
    const proto = req.headers.get("x-forwarded-proto") ?? "http";

    if (proto === "http") {
      return new Response(null, {
        status: 301,
        headers: {
          Location: `https://${host}${url.pathname}${url.search}`,
        },
      });
    }

    return new Response("Hello from Bun", {
      headers: {
        "Content-Type": "text/plain; charset=utf-8",
        "Strict-Transport-Security": HSTS_VALUE,
      },
    });
  },
});

A few things to call out:

  • I started with max-age=300, which is 5 minutes.
  • That short value is deliberate.
  • You do not jump straight to one year unless you’re very sure your setup is clean.

This first deployment let the team test behavior safely. If something broke, waiting a few minutes would clear the browser policy.

The staging mistake nobody wants to make

The team’s first instinct was to use this header everywhere, including staging:

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

That would have been a mess.

Why? Because staging shared the parent domain with other internal subdomains, and some of them were still inconsistent about HTTPS. If a browser cached includeSubDomains for the parent domain, those broken subdomains could become inaccessible.

That’s the gotcha with HSTS: it’s easy to enable, but its scope matters a lot.

My rule is simple:

  • Start with the exact production host only
  • Use a short max-age
  • Confirm every subdomain is HTTPS-clean before adding includeSubDomains
  • Treat preload as a separate project, not a checkbox

After: a production-safe rollout

Once the short test period passed cleanly, they moved to a stronger policy for the main production host:

import { serve } from "bun";

const HSTS_VALUE = "max-age=31536000";

serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);
    const host = req.headers.get("host") ?? "localhost";
    const proto = req.headers.get("x-forwarded-proto") ?? "http";

    if (proto === "http") {
      return new Response(null, {
        status: 301,
        headers: {
          Location: `https://${host}${url.pathname}${url.search}`,
        },
      });
    }

    const headers = new Headers({
      "Content-Type": "text/html; charset=utf-8",
      "Strict-Transport-Security": HSTS_VALUE,
    });

    return new Response("<h1>Secure Bun app</h1>", {
      status: 200,
      headers,
    });
  },
});

That got them the core protection they actually needed.

Later, after auditing subdomains, they upgraded again:

const HSTS_VALUE = "max-age=31536000; includeSubDomains";

That change should only happen after you verify every subdomain supports HTTPS correctly, including forgotten ones like:

  • m.example.com
  • api.example.com
  • old-admin.example.com
  • cdn.example.com
  • dev.example.com if it’s publicly reachable

If one of those still serves plain HTTP or has a bad cert path, you will hear about it fast.

What changed after rollout

The “after” state was not dramatic from the user’s perspective. That’s normal. Security controls usually look boring when they work.

But technically, the app improved in a few real ways:

Before

  • First visit over HTTP could be intercepted
  • Redirect relied on network trust
  • Browsers had no memory of HTTPS-only policy
  • Old links and bookmarks still triggered insecure first hops

After

  • Returning browsers upgraded requests to HTTPS automatically
  • SSL stripping risk dropped sharply after first secure visit
  • Security header scans stopped flagging missing HSTS
  • The team had a clear path to stronger domain-wide enforcement

That’s the practical value. Not fancy. Just less exposed.

Bun-specific deployment advice

Bun itself doesn’t make HSTS hard. The main concern is where TLS is terminated.

If Bun is behind a reverse proxy or load balancer, your app probably won’t see native HTTPS directly. It will see forwarded headers like:

X-Forwarded-Proto: https

That means your logic needs to trust the right proxy path and only emit HSTS when the original client connection was HTTPS.

If you terminate TLS directly in Bun, the header logic is even simpler. You can attach HSTS to all normal secure responses and keep redirects for the separate HTTP listener.

Bun’s official docs for server setup are here: https://bun.sh/docs/api/http

A safer helper for Bun responses

If you want something cleaner than repeating the header everywhere, wrap it:

function secureHeaders(proto: string, baseHeaders: HeadersInit = {}) {
  const headers = new Headers(baseHeaders);

  if (proto === "https") {
    headers.set("Strict-Transport-Security", "max-age=31536000");
  }

  return headers;
}

Use it like this:

import { serve } from "bun";

serve({
  port: 3000,
  fetch(req) {
    const proto = req.headers.get("x-forwarded-proto") ?? "http";
    const url = new URL(req.url);
    const host = req.headers.get("host") ?? "localhost";

    if (proto === "http") {
      return new Response(null, {
        status: 301,
        headers: {
          Location: `https://${host}${url.pathname}${url.search}`,
        },
      });
    }

    return new Response(JSON.stringify({ ok: true }), {
      headers: secureHeaders(proto, {
        "Content-Type": "application/json; charset=utf-8",
      }),
    });
  },
});

That makes it much harder for a future route to forget the header.

What I’d actually recommend

If you’re running a Bun server in production, I’d do this:

  1. Confirm HTTPS works everywhere for the main host.
  2. Add Strict-Transport-Security: max-age=300 on HTTPS responses.
  3. Test login flows, asset loading, callback URLs, and old bookmarks.
  4. Increase to max-age=31536000 once stable.
  5. Audit all subdomains before adding includeSubDomains.
  6. Don’t touch preload until you fully understand the blast radius.

That sequence is boring, cautious, and correct.

HSTS is one of those headers that gives you a lot for very little code. The danger is not adding it. The danger is adding too much too fast and discovering that some forgotten subdomain still lives in 2017.

If you want a quick sanity check on your deployed headers, run the site through headertest.com?utm_source=hsts-guide&utm_medium=blog&utm_campaign=article-link. It’s a fast way to catch the “we thought the proxy added it” kind of mistake.

For Bun apps, the implementation is easy. The rollout discipline is the real work.