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.comget rewritten tohttps://example.comby the browser before the request is sent - if you use
includeSubDomains, the same rule applies toapi.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:
- Your production site is fully HTTPS-only
- Every HTTP request redirects to HTTPS
- Your TLS certificate is valid and auto-renewed
- If you plan to use
includeSubDomains, every subdomain supports HTTPS too - 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 testingmax-age=86400— 1 day, decent for cautious rolloutmax-age=31536000— 1 year, common production setting
My advice: start small if this is your first rollout on a real domain.
Example rollout plan:
- deploy
max-age=300 - verify redirects, certs, and subdomains
- bump to
max-age=86400 - 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-ageof at least 1 yearincludeSubDomainspreload- 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.
Recommended setup for most Vapor apps
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=31536000after validation - use
includeSubDomainsonly when you’ve audited all subdomains - use
preloadonly 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.