If you’re shipping a Vapor app over HTTPS and you haven’t enabled HSTS yet, you’re leaving a pretty dumb gap in your transport security.

HSTS stands for HTTP Strict Transport Security. It tells browsers: “for this domain, stop trying plain HTTP and always use HTTPS.” That matters because a redirect from http:// to https:// is not enough. An attacker sitting on the network can tamper with that first insecure request before the browser ever gets redirected.

HSTS closes that downgrade window after the browser has seen the header once.

For Vapor apps, HSTS is easy to add. The harder part is getting the policy right without breaking subdomains, staging environments, or preload eligibility.

What HSTS actually does

When your app returns this response header:

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

the browser caches that policy for the domain for 1 year.

From then on:

  • requests to http://example.com get rewritten to https://example.com by the browser before the request is sent
  • if you use includeSubDomains, the same rule applies to api.example.com, app.example.com, and every other subdomain
  • if the certificate is invalid, the browser blocks access hard instead of offering a “continue anyway” path in many cases

That last part is one reason HSTS is powerful and one reason it can bite you if you roll it out carelessly.

HSTS prerequisites

Before adding the header in Vapor, make sure these are true:

  1. Your production site is fully HTTPS-only
  2. Every HTTP request redirects to HTTPS
  3. Your TLS certificate is valid and auto-renewed
  4. If you plan to use includeSubDomains, every subdomain supports HTTPS too
  5. If you plan to preload, your whole domain setup is ready for a long-term commitment

If any of those are shaky, fix them first.

The basic HSTS header for Vapor

In Vapor, the cleanest approach is middleware.

Create a middleware type that adds the Strict-Transport-Security header to every response.

import Vapor

struct HSTSMiddleware: Middleware {
    let value: String

    init(
        maxAge: Int = 31536000,
        includeSubdomains: Bool = false,
        preload: Bool = false
    ) {
        var parts = ["max-age=\(maxAge)"]

        if includeSubdomains {
            parts.append("includeSubDomains")
        }

        if preload {
            parts.append("preload")
        }

        self.value = parts.joined(separator: "; ")
    }

    func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture<Response> {
        next.respond(to: request).map { response in
            response.headers.replaceOrAdd(name: .strictTransportSecurity, value: self.value)
            return response
        }
    }
}

Then register it in configure.swift:

import Vapor

public func configure(_ app: Application) throws {
    app.middleware.use(
        HSTSMiddleware(
            maxAge: 31536000,
            includeSubdomains: true,
            preload: false
        )
    )

    try routes(app)
}

That gives you:

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

For most production apps, that’s a solid starting point.

Only enable HSTS in production

Don’t blindly enable HSTS for local development. It’s annoying at best and confusing at worst.

If you accidentally set HSTS on localhost via a weird proxy setup or on a dev domain you reuse, browsers can cache it and keep forcing HTTPS even when your local app isn’t serving TLS.

A better pattern is environment-based configuration:

import Vapor

public func configure(_ app: Application) throws {
    if app.environment == .production {
        app.middleware.use(
            HSTSMiddleware(
                maxAge: 31536000,
                includeSubdomains: true,
                preload: false
            )
        )
    }

    try routes(app)
}

If you have multiple deployed environments like staging.example.com and example.com, think hard before using includeSubDomains from the parent domain. I’ve seen teams break staging because they enabled an aggressive HSTS policy on the apex domain without checking every subdomain.

Redirect HTTP to HTTPS too

HSTS is not a substitute for redirects. You still want HTTP requests to land on HTTPS cleanly for first-time visitors and bots.

If you terminate TLS at a reverse proxy like Nginx, Caddy, AWS ALB, or Cloudflare, do the redirect there. That’s usually the right place.

A basic Nginx example:

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

Then your Vapor app, running behind the proxy, serves normal HTTPS traffic and adds the HSTS header.

If Vapor is directly exposed and handling HTTPS itself, you still need an HTTP listener that redirects. In practice, most production Vapor deployments sit behind a proxy or load balancer, and that’s where I’d keep the redirect logic.

Choosing the right max-age

max-age is in seconds.

Common values:

  • max-age=300 — 5 minutes, good for initial testing
  • max-age=86400 — 1 day, decent for cautious rollout
  • max-age=31536000 — 1 year, common production setting

My advice: start small if this is your first rollout on a real domain.

Example rollout plan:

  1. deploy max-age=300
  2. verify redirects, certs, and subdomains
  3. bump to max-age=86400
  4. after a few days of confidence, move to max-age=31536000

That’s much safer than jumping straight to a one-year policy and discovering some forgotten subdomain still serves plain HTTP.

When to use includeSubDomains

includeSubDomains sounds great because it closes gaps across your domain. It also expands the blast radius.

Use it only if every subdomain is HTTPS-ready, including old admin panels, support tools, status pages, and random marketing leftovers.

Example header:

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

If you own example.com and oldcrm.example.com still has broken TLS, browsers that have cached this policy will refuse to connect properly.

That’s not a browser bug. That’s your policy doing exactly what you told it to do.

Preload: useful, but a commitment

HSTS has one big weakness: the browser must see the header once before it can protect future requests.

Preloading solves that by shipping your domain inside browser preload lists.

To be eligible, you generally need:

  • max-age of at least 1 year
  • includeSubDomains
  • preload
  • HTTPS on the whole domain tree

Header example:

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

In Vapor:

app.middleware.use(
    HSTSMiddleware(
        maxAge: 31536000,
        includeSubdomains: true,
        preload: true
    )
)

I would not enable preload casually. Once your domain is preloaded in browsers, backing out is slow and annoying. If you manage a stable, mature domain and you know every subdomain is under control, preload is worth considering. If your infrastructure is messy, hold off.

Make sure proxies don’t strip the header

A common production gotcha: Vapor adds the header, but the edge proxy rewrites or strips headers downstream.

Check the final response seen by the browser, not just what your app logs internally.

You can inspect it with curl:

curl -I https://example.com

You should see something like:

HTTP/2 200
strict-transport-security: max-age=31536000; includeSubDomains

You can also run a free security headers scan at HeaderTest if you want a quick external check.

A stricter middleware example for production

If you want to guarantee HSTS is only added on secure requests, you can guard it. This matters more in unusual topologies where your app might serve mixed traffic.

import Vapor

struct ConditionalHSTSMiddleware: Middleware {
    let hstsValue: String

    init() {
        self.hstsValue = "max-age=31536000; includeSubDomains"
    }

    func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture<Response> {
        next.respond(to: request).map { response in
            let forwardedProto = request.headers.first(name: "X-Forwarded-Proto")
            let isHTTPS = request.url.scheme == "https" || forwardedProto == "https"

            if isHTTPS {
                response.headers.replaceOrAdd(
                    name: .strictTransportSecurity,
                    value: hstsValue
                )
            }

            return response
        }
    }
}

Then:

if app.environment == .production {
    app.middleware.use(ConditionalHSTSMiddleware())
}

If you’re behind a trusted proxy, make sure your forwarding config is correct. Otherwise your app may mis-detect the original scheme.

How to disable HSTS if you mess up

You can tell browsers to clear the policy by sending:

Strict-Transport-Security: max-age=0

In Vapor:

app.middleware.use(HSTSMiddleware(maxAge: 0))

That helps for domains that are not preloaded and only after browsers receive the new header over HTTPS.

If you already shipped a long max-age with includeSubDomains, users may keep the old policy until they revisit over HTTPS and receive the clearing header. If the domain is preloaded, removal is slower and depends on browser list updates.

That’s why I’m pretty conservative about preload.

If you want the practical version, here’s what I’d do for a normal production Vapor app:

  • redirect HTTP to HTTPS at the proxy
  • add HSTS in Vapor or at the proxy edge
  • start with max-age=86400
  • move to max-age=31536000 after validation
  • use includeSubDomains only when you’ve audited all subdomains
  • use preload only when you’re very sure you want it long term

A good production middleware config often looks like this:

if app.environment == .production {
    app.middleware.use(
        HSTSMiddleware(
            maxAge: 31536000,
            includeSubdomains: true,
            preload: false
        )
    )
}

That gets you most of the value without locking you into preload too early.

HSTS is one of those headers that’s easy to add and easy to get wrong at the domain level. Vapor doesn’t make it hard. The real work is knowing your infrastructure well enough to set a policy you can actually live with.