I’ve seen a lot of teams treat HSTS like a checkbox header: add one line, ship it, move on. That mindset is how you brick subdomains, lock users into bad TLS setups, or convince yourself you’re “secure” while your first request is still vulnerable.

If you’re serving a Zig app with Zap, HSTS is simple to add, but the hard part is knowing when to add it, how aggressively to configure it, and how to roll it out without surprising production.

Here’s a real-world style case study of a small Zap service moving from “HTTPS exists” to “HTTPS is enforced properly.”

The setup

The app was a small internal tool that became customer-facing over time. Pretty common story:

  • Zig backend
  • Zap for HTTP
  • TLS terminated at a reverse proxy in production
  • Local development over plain HTTP
  • One main hostname: app.example.com
  • A few older subdomains still hanging around

The team had already enabled HTTPS at the edge, but they hadn’t set HSTS. On paper, traffic was encrypted. In practice, users could still start on http://app.example.com, get redirected, and be exposed to downgrade or SSL stripping attacks on that first hop.

That gap matters most on hostile networks: hotel Wi-Fi, coffee shop Wi-Fi, enterprise guest networks, anywhere a machine in the middle can interfere.

Before: HTTPS redirect, no HSTS

This was roughly the original behavior. Requests arriving over HTTP were redirected to HTTPS, and HTTPS traffic worked fine.

const std = @import("std");
const zap = @import("zap");

fn on_request(r: zap.Request) void {
    const path = r.path orelse "/";
    const host = r.getHeader("host") orelse "app.example.com";

    // Assume proxy forwards this header
    const forwarded_proto = r.getHeader("x-forwarded-proto") orelse "http";

    if (std.mem.eql(u8, forwarded_proto, "http")) {
        var location_buf: [512]u8 = undefined;
        const location = std.fmt.bufPrint(
            &location_buf,
            "https://{s}{s}",
            .{ host, path },
        ) catch {
            r.setStatus(.internal_server_error);
            r.sendBody("failed to build redirect") catch {};
            return;
        };

        r.setStatus(.moved_permanently);
        r.setHeader("Location", location) catch {};
        r.sendBody("") catch {};
        return;
    }

    r.setStatus(.ok);
    r.setHeader("Content-Type", "text/plain; charset=utf-8") catch {};
    r.sendBody("secure area") catch {};
}

pub fn main() !void {
    var listener = zap.HttpListener.init(.{
        .port = 3000,
        .on_request = on_request,
        .log = true,
    });
    try listener.listen();
    zap.start(.{ .threads = 2, .workers = 1 });
}

This is better than serving plain HTTP, but it still leaves a real weakness:

  1. User types example.com
  2. Browser makes an HTTP request first
  3. Server redirects to HTTPS
  4. Browser retries over HTTPS

That first request is the problem. If an attacker can tamper with it, your redirect may never reach the browser.

What they thought was enough

The team’s reasoning was familiar:

  • “We already redirect HTTP to HTTPS.”
  • “The load balancer handles TLS.”
  • “We don’t want to break staging.”
  • “We’ll do preload later.”

I get it. Nobody wants to turn on includeSubDomains and discover an old support portal still serves an expired certificate from 2019.

But no HSTS means the browser is still willing to talk HTTP first. That’s exactly what HSTS is designed to stop after the first trusted HTTPS visit.

The fix: add HSTS on HTTPS responses only

The corrected setup did two things:

  • Keep the HTTP-to-HTTPS redirect
  • Add Strict-Transport-Security on HTTPS responses only

That last part matters. Browsers ignore HSTS sent over HTTP anyway, and if your app logic is behind a proxy, you need to be careful not to emit it on the wrong code path.

Here’s the improved version:

const std = @import("std");
const zap = @import("zap");

const HSTS_VALUE = "max-age=300";

fn isHttps(r: zap.Request) bool {
    const forwarded_proto = r.getHeader("x-forwarded-proto") orelse "http";
    return std.mem.eql(u8, forwarded_proto, "https");
}

fn redirectToHttps(r: zap.Request) void {
    const path = r.path orelse "/";
    const host = r.getHeader("host") orelse "app.example.com";

    var location_buf: [512]u8 = undefined;
    const location = std.fmt.bufPrint(
        &location_buf,
        "https://{s}{s}",
        .{ host, path },
    ) catch {
        r.setStatus(.internal_server_error);
        r.sendBody("failed to build redirect") catch {};
        return;
    };

    r.setStatus(.moved_permanently);
    r.setHeader("Location", location) catch {};
    r.sendBody("") catch {};
}

fn on_request(r: zap.Request) void {
    if (!isHttps(r)) {
        redirectToHttps(r);
        return;
    }

    // Only send HSTS over HTTPS
    r.setHeader("Strict-Transport-Security", HSTS_VALUE) catch {};
    r.setHeader("Content-Type", "text/plain; charset=utf-8") catch {};
    r.setStatus(.ok);
    r.sendBody("secure area") catch {};
}

pub fn main() !void {
    var listener = zap.HttpListener.init(.{
        .port = 3000,
        .on_request = on_request,
        .log = true,
    });
    try listener.listen();
    zap.start(.{ .threads = 2, .workers = 1 });
}

The first rollout used:

Strict-Transport-Security: max-age=300

That’s just 5 minutes. Intentionally tiny.

A lot of blog posts jump straight to one year plus preload. I think that’s reckless if you haven’t audited all subdomains and certificate paths. Short max-age first, verify behavior, then increase it.

Why the short max-age was the right call

Within a day, they found two issues:

  • A forgotten subdomain was still HTTP-only
  • A monitoring check used a direct HTTP probe and started failing once browser behavior changed in testing

Neither problem was catastrophic because the initial max-age was short. If they had started with:

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

they would have created a mess for themselves.

That’s the thing with HSTS: once browsers cache it, you don’t get instant take-backs. A bad policy can stick around for months.

After: production-ready HSTS

Once the domain inventory was cleaned up and every relevant host had valid TLS, they moved to a stronger header:

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

And later, once they were truly confident:

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

That final step should only happen if you actually intend to submit the domain to browser preload lists and meet the preload requirements. Don’t slap preload on the header because it looks impressive.

A more realistic mature handler looked like this:

const std = @import("std");
const zap = @import("zap");

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

fn isHttps(r: zap.Request) bool {
    const proto = r.getHeader("x-forwarded-proto") orelse "http";
    return std.mem.eql(u8, proto, "https");
}

fn setSecurityHeaders(r: zap.Request) void {
    r.setHeader("Strict-Transport-Security", HSTS_VALUE) catch {};
    r.setHeader("X-Content-Type-Options", "nosniff") catch {};
    r.setHeader("Referrer-Policy", "strict-origin-when-cross-origin") catch {};
}

fn redirectToHttps(r: zap.Request) void {
    const path = r.path orelse "/";
    const host = r.getHeader("host") orelse "app.example.com";

    var location_buf: [512]u8 = undefined;
    const location = std.fmt.bufPrint(&location_buf, "https://{s}{s}", .{ host, path }) catch {
        r.setStatus(.internal_server_error);
        r.sendBody("redirect failed") catch {};
        return;
    };

    r.setStatus(.moved_permanently);
    r.setHeader("Location", location) catch {};
    r.sendBody("") catch {};
}

fn on_request(r: zap.Request) void {
    if (!isHttps(r)) {
        redirectToHttps(r);
        return;
    }

    setSecurityHeaders(r);

    r.setStatus(.ok);
    r.setHeader("Content-Type", "application/json; charset=utf-8") catch {};
    r.sendBody("{\"status\":\"ok\"}") catch {};
}

pub fn main() !void {
    var listener = zap.HttpListener.init(.{
        .port = 3000,
        .on_request = on_request,
        .log = true,
    });
    try listener.listen();
    zap.start(.{ .threads = 2, .workers = 1 });
}

The reverse proxy gotcha

This team was running Zap behind a proxy, which means the app only knew the original scheme because of X-Forwarded-Proto.

That’s fine, but only if:

  • the reverse proxy always sets it
  • direct access to the app is blocked
  • you trust the proxy path

If your Zap service is directly reachable from the internet and you blindly trust X-Forwarded-Proto, a client can spoof it. That can break your security logic in ugly ways.

My rule: if you’re behind a proxy, make sure your app is not publicly reachable except through that proxy. Otherwise your “HTTPS detection” is theater.

Verifying the result

After rollout, they checked:

  • HTTP requests redirect to HTTPS
  • HTTPS responses include HSTS
  • no mixed-scheme links remained
  • subdomains all had valid certificates
  • staging was excluded from production HSTS assumptions

A quick external scan is useful here. If you want a fast sanity check on HSTS and the rest of your headers, run a free scan at HeaderTest. I like having an outside view because it catches mistakes that are easy to miss when you only test from inside your stack.

What changed in practice

After HSTS was in place, repeat visitors no longer depended on that initial HTTP redirect. Their browsers learned: this host is HTTPS-only.

That gave the team three concrete wins:

  • less downgrade risk on hostile networks
  • fewer accidental insecure requests from old bookmarks and typed URLs
  • clearer operational discipline around TLS and subdomain ownership

The code change was tiny. The operational change was the real work.

The rollout plan I’d recommend

If you’re adding HSTS to a Zig app with Zap today, I’d do it in this order:

  1. Make sure HTTPS works everywhere you care about
  2. Keep your HTTP redirect
  3. Ship max-age=300 first
  4. Watch for broken subdomains, cert gaps, and tooling issues
  5. Increase to a month
  6. Increase to a year
  7. Only then consider includeSubDomains and preload

If you already know every subdomain is covered and managed well, you can move faster. Most teams don’t know that as well as they think they do.

The main lesson

HSTS for Zap isn’t hard technically. It’s hard organizationally.

The “before” version had HTTPS, but users could still start insecurely. The “after” version taught browsers to stop doing that. That’s the whole point.

Use a short max-age first. Don’t trust forwarded headers unless your network path is locked down. Don’t enable includeSubDomains on vibes. And don’t add preload unless you mean it.

That’s how you make HSTS boring in production, which is exactly what you want.