If you run Envoy at the edge, HSTS is one of those headers you should set deliberately instead of “getting around to it later”. It is simple on paper: tell browsers to always use HTTPS for your site. In practice, the question is where to inject it in an Envoy-based stack, and that choice affects operability, consistency, and the blast radius of mistakes.
For Envoy, there are a few common approaches:
- Set HSTS directly in Envoy response headers
- Set it in the upstream application and let Envoy pass it through
- Use a service mesh or ingress layer built on Envoy to manage it centrally
- Add it conditionally with Lua or Wasm when you need custom behavior
I’ll compare these options, show the tradeoffs, and call out the mistakes I see most often.
Quick HSTS refresher
The header looks like this:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Common directives:
max-age=31536000— browsers remember HTTPS-only for 1 yearincludeSubDomains— applies policy to all subdomainspreload— asks browsers to hardcode your domain into preload lists
If you are just rolling this out, I usually start with:
Strict-Transport-Security: max-age=300
That gives you a short rollback window. Once you are sure HTTPS is clean everywhere, move to something stronger:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Only add preload if you fully understand the commitment.
Option 1: Set HSTS directly in Envoy
This is the most straightforward setup for teams using Envoy as the edge proxy. Envoy can append response headers in the route config, virtual host config, or HTTP connection manager level depending on how you structure your config.
A typical route-level example:
static_resources:
listeners:
- name: https_listener
address:
socket_address:
address: 0.0.0.0
port_value: 443
filter_chains:
- transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
common_tls_context:
tls_certificates:
- certificate_chain:
filename: /etc/envoy/tls/fullchain.pem
private_key:
filename: /etc/envoy/tls/privkey.pem
filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: app
domains: ["example.com", "www.example.com"]
routes:
- match:
prefix: "/"
route:
cluster: app_service
response_headers_to_add:
- header:
key: Strict-Transport-Security
value: "max-age=31536000; includeSubDomains"
append_action: OVERWRITE_IF_EXISTS_OR_ADD
http_filters:
- name: envoy.filters.http.router
Pros
- Centralized at the edge. Every upstream app gets the same policy.
- Easy to audit. You know where the header is coming from.
- Low app burden. Backend teams do not need framework-specific header config.
- Good fit for platform teams. If Envoy is your enforcement point, this keeps policy clean.
Cons
- Can be too broad. If the same Envoy instance fronts multiple domains, you need to avoid applying one HSTS policy blindly to all hosts.
- Less app context. Envoy does not inherently know whether a particular route or hostname is ready for
includeSubDomains. - Rollback still has browser cache lag. Not an Envoy problem specifically, but people blame the proxy when HSTS sticks around after config changes.
My take
If Envoy is your public edge, this is usually the best default. It is boring, predictable, and easy to standardize. I prefer edge-managed HSTS over asking every app team to remember security headers.
Option 2: Set HSTS in the upstream application
Instead of Envoy owning the header, your app emits it and Envoy passes it through unchanged.
Example in Express:
app.use((req, res, next) => {
res.setHeader(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains"
);
next();
});
Example in Go:
func hsts(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
next.ServeHTTP(w, r)
})
}
Pros
- Per-app control. Teams can tailor policy by domain or deployment stage.
- Closer to business context. The app often knows whether a subdomain is safe to include.
- Portable. If you move away from Envoy later, the app keeps the policy.
Cons
- Inconsistent across services. This is the big one. Some apps will set it, some will forget, some will set different values.
- Harder to audit at scale. You end up checking framework code across multiple repos.
- Easy to break in local dev assumptions. Teams sometimes add weird conditionals because they are mixing HTTP dev traffic with production code paths.
- Not ideal for shared ownership. Platform and app teams may both think the other side owns headers.
My take
I only like this when a service is independently exposed and has a good reason to own its own security policy. In a large fleet, app-managed HSTS tends to drift.
Option 3: Manage HSTS through an Envoy-based ingress or mesh layer
A lot of teams do not run raw Envoy config by hand. They use a control plane, Kubernetes ingress, or service mesh that programs Envoy for them. In that case, HSTS may be configured through higher-level resources instead of native Envoy YAML.
This is attractive because it gives you policy-as-platform. One config template can apply HSTS to many services.
Pros
- Best consistency. Great for standardizing across dozens or hundreds of workloads.
- Safer change management. You can stage policy updates through your normal platform rollout flow.
- Cleaner ownership. Security and platform teams can enforce baseline headers centrally.
Cons
- Abstraction leaks. Your ingress layer may not expose every Envoy feature cleanly.
- Debugging can be annoying. When a header is missing, you now have to inspect generated Envoy config, not just the user-facing resource.
- Risk of over-enforcement. One bad template can apply
includeSubDomainsto domains that are not ready.
My take
For Kubernetes-heavy shops, this is often the right operational answer. Just make sure your abstraction lets you scope HSTS per hostname, not globally across unrelated tenants.
Option 4: Add HSTS conditionally with Lua or Wasm
Sometimes you need logic. Maybe only certain hostnames should get HSTS. Maybe you want to avoid setting it on internal domains. Envoy’s Lua filter can do that.
Example Lua filter:
http_filters:
- name: envoy.filters.http.lua
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inline_code: |
function envoy_on_response(response_handle)
local authority = response_handle:headers():get(":authority")
if authority == "example.com" or authority == "www.example.com" then
response_handle:headers():replace(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains"
)
end
end
- name: envoy.filters.http.router
Pros
- Very flexible. Good for multi-tenant or mixed-domain setups.
- Can encode exceptions without duplicating route blocks everywhere.
- Useful during migrations. You can gradually enable HSTS only where ready.
Cons
- More moving parts. Logic in filters is harder to reason about than static config.
- Easier to create hidden policy. Six months later, someone forgets the Lua script even exists.
- Potential performance and maintenance overhead compared to static header injection.
My take
Use this when you genuinely need conditional behavior. Do not reach for Lua just because static config feels repetitive. Repetitive config is often easier to maintain than clever filter logic.
Comparison table
Here’s the short version:
Envoy static header config
Best for: edge proxies, centralized teams
Pros: simple, auditable, consistent
Cons: needs careful hostname scoping
App-level header
Best for: independently managed apps with unique policies
Pros: contextual, portable
Cons: inconsistent at scale
Ingress/mesh-managed config
Best for: platform-heavy Kubernetes environments
Pros: standardized, scalable
Cons: abstraction and debugging complexity
Lua/Wasm conditional logic
Best for: exceptions, migrations, multi-tenant logic
Pros: flexible
Cons: complexity, hidden behavior
Common mistakes with HSTS on Envoy
Setting HSTS on HTTP responses
Do not send HSTS over plain HTTP and assume that solves anything. Browsers only honor HSTS received over HTTPS. Your HTTP listener should redirect to HTTPS, and your HTTPS listener should send HSTS.
Enabling includeSubDomains too early
If any subdomain is still HTTP-only, legacy, or misconfigured, you can break access for users. I have seen this happen with forgotten admin panels and old mail-related hosts.
Jumping straight to preload
Preload is not a badge of honor. It is a commitment. If you are not fully HTTPS everywhere, skip it.
Letting duplicate headers pile up
Be explicit with Envoy’s append behavior. I prefer:
append_action: OVERWRITE_IF_EXISTS_OR_ADD
That avoids weird cases where upstream and Envoy both set HSTS.
A practical rollout plan
- Redirect all HTTP traffic to HTTPS
- Add HSTS in Envoy with
max-age=300 - Validate responses across all public hostnames
- Raise to
max-age=31536000 - Add
includeSubDomainsonly after inventorying subdomains - Consider preload last
If you want to verify your live headers quickly, run a scan with HeaderTest. For Envoy behavior and config details, the canonical reference is the official Envoy documentation.
My recommendation
For most developer teams, set HSTS at the Envoy edge unless you have a strong reason not to. It gives you consistency without forcing every application stack to reinvent header policy. If you are on Kubernetes with an Envoy-based ingress, manage it there for the same reason. Keep app-level HSTS for exceptions, not as the default pattern.
And keep the first rollout conservative. HSTS is easy to enable and annoyingly sticky to undo. That is exactly why it works.