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:

  1. Set HSTS directly in Envoy response headers
  2. Set it in the upstream application and let Envoy pass it through
  3. Use a service mesh or ingress layer built on Envoy to manage it centrally
  4. 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 year
  • includeSubDomains — applies policy to all subdomains
  • preload — 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 includeSubDomains to 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

  1. Redirect all HTTP traffic to HTTPS
  2. Add HSTS in Envoy with max-age=300
  3. Validate responses across all public hostnames
  4. Raise to max-age=31536000
  5. Add includeSubDomains only after inventorying subdomains
  6. 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.