HTTP Strict Transport Security is one of those headers that looks trivial until you ship it wrong and lock users into a bad config for months.

If you run apps on Azure or GCP, the main challenge usually is not the header itself. It’s figuring out where to set it so it’s applied consistently, survives redirects, and doesn’t get stripped by a proxy, CDN, or app server. This guide is the practical version: what to send, where to send it, and copy-paste examples.

The header you actually want

A typical production HSTS header looks like this:

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

If you want to be considered for browser preload lists, use:

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

What the directives mean

  • max-age=31536000 — tells browsers to remember HTTPS-only for 1 year
  • includeSubDomains — applies the policy to all subdomains
  • preload — signals intent for browser preload programs

My advice: don’t jump straight to preload unless you’re very sure every current and future subdomain can serve HTTPS correctly.

Safe rollout strategy

I’ve seen teams go straight to one year, then discover some forgotten subdomain still serves plain HTTP. That gets ugly fast.

Use a staged rollout:

Strict-Transport-Security: max-age=300

Then:

Strict-Transport-Security: max-age=86400

Then:

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

Only after you’ve validated everything should you consider:

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

Rules that matter in production

  1. Only send HSTS over HTTPS. Browsers ignore it on HTTP.
  2. Redirect HTTP to HTTPS before anything else.
  3. Set it at the edge when possible. That gives you consistency across apps.
  4. Don’t use includeSubDomains casually.
  5. Don’t preload unless you’re ready for the commitment.

If you want a quick check after deployment, run a scan at headertest.com.


Azure

Azure App Service

If your app runs directly on App Service, you can set HSTS in the application itself or in web server config. I generally prefer app-level config when the stack supports it cleanly, because it stays with the app. If multiple apps sit behind a shared edge, I prefer edge config.

ASP.NET Core

In Program.cs:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

app.UseHttpsRedirection();

app.MapGet("/", () => "Hello over HTTPS");
app.Run();

Customize it:

using Microsoft.AspNetCore.HttpsPolicy;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHsts(options =>
{
    options.MaxAge = TimeSpan.FromDays(365);
    options.IncludeSubDomains = true;
    options.Preload = false;
});

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseHsts();
}

app.UseHttpsRedirection();
app.Run();

IIS on App Service with web.config

For Windows App Service hosting classic ASP.NET or IIS-backed apps:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <system.webServer>
    <httpProtocol>
      <customHeaders>
        <add name="Strict-Transport-Security" value="max-age=31536000; includeSubDomains" />
      </customHeaders>
    </httpProtocol>
    <rewrite>
      <rules>
        <rule name="HTTP to HTTPS" stopProcessing="true">
          <match url="(.*)" />
          <conditions>
            <add input="{HTTPS}" pattern="off" ignoreCase="true" />
          </conditions>
          <action type="Redirect" url="https://{HTTP_HOST}/{R:1}" redirectType="Permanent" />
        </rule>
      </rules>
    </rewrite>
  </system.webServer>
</configuration>

Node.js / Express on App Service

Use Helmet:

const express = require('express');
const helmet = require('helmet');

const app = express();

app.use(helmet.hsts({
  maxAge: 31536000,
  includeSubDomains: true,
  preload: false
}));

app.get('/', (req, res) => {
  res.send('Hello over HTTPS');
});

app.listen(process.env.PORT || 3000);

If App Service terminates TLS upstream, make sure Express trusts the proxy so HTTPS redirects and secure behavior work correctly:

app.set('trust proxy', 1);

Azure Front Door

If you use Front Door, this is one of the best places to inject HSTS because it applies consistently at the edge.

Using Rule Set behavior, add or overwrite the response header:

  • Header name: Strict-Transport-Security
  • Value: max-age=31536000; includeSubDomains

You can also do this with Azure CLI once your rule set exists.

Example pattern:

az afd rule action add \
  --resource-group my-rg \
  --profile-name my-frontdoor \
  --rule-set-name security-headers \
  --rule-name add-hsts \
  --action-name ModifyResponseHeader \
  --header-action Overwrite \
  --header-name Strict-Transport-Security \
  --value "max-age=31536000; includeSubDomains"

Then make sure HTTP is redirected to HTTPS in your route configuration.

What I like about Front Door for HSTS:

  • one place to manage it
  • less drift across services
  • easy to standardize across multiple backends

What I don’t like:

  • teams sometimes forget the backend also emits a different HSTS value, and now debugging becomes annoying

Pick one source of truth if you can.

Azure Application Gateway

Application Gateway can rewrite response headers. Add a rewrite rule set that injects HSTS:

az network application-gateway rewrite-rule set create \
  --resource-group my-rg \
  --gateway-name my-appgw \
  --name securityHeaders

Then add a rewrite rule and response header configuration:

az network application-gateway rewrite-rule create \
  --resource-group my-rg \
  --gateway-name my-appgw \
  --rule-set-name securityHeaders \
  --name addHsts \
  --sequence 100
az network application-gateway rewrite-rule action set \
  --resource-group my-rg \
  --gateway-name my-appgw \
  --rule-set-name securityHeaders \
  --rule-name addHsts \
  --response-headers "Strict-Transport-Security=max-age=31536000; includeSubDomains"

Also configure an HTTP listener and redirect configuration so plain HTTP never serves content.

Azure CDN / edge cases

If you’re using Azure CDN or a Microsoft edge service in front of your app, verify whether the product supports response header modification in your chosen SKU. This changes over time, and Azure has a habit of making capability matrices more confusing than they need to be.

If the edge can set the header, do it there. If not, set it in the origin app and verify the CDN forwards it unchanged.

Official docs worth checking:

  • Azure App Service documentation
  • Azure Front Door documentation
  • Azure Application Gateway documentation

GCP

Cloud Load Balancing with backend services

On GCP, the cleanest place to add HSTS is often the external HTTP(S) Load Balancer using custom response headers on the backend service.

Example with gcloud:

gcloud compute backend-services update my-backend-service \
  --global \
  --custom-response-header="Strict-Transport-Security: max-age=31536000; includeSubDomains"

If you use regional load balancing, use the regional variant of the command.

Then create or verify an HTTP-to-HTTPS redirect. For classic global external Application Load Balancers, the redirect is usually configured on the URL map and target proxy setup.

The nice thing here is the same as Front Door: one edge policy, many backends.

Cloud Run

If Cloud Run sits behind a GCP load balancer, I prefer setting HSTS at the load balancer. If Cloud Run is exposed more directly, set it in the app.

Node.js / Express on Cloud Run

const express = require('express');
const helmet = require('helmet');

const app = express();
app.set('trust proxy', 1);

app.use(helmet.hsts({
  maxAge: 31536000,
  includeSubDomains: true,
  preload: false
}));

app.get('/', (req, res) => {
  res.send('Hello from Cloud Run');
});

const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

Python / Flask on Cloud Run

from flask import Flask

app = Flask(__name__)

@app.after_request
def add_hsts(response):
    response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
    return response

@app.route("/")
def hello():
    return "Hello from Cloud Run"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

GKE with NGINX Ingress

If you run GKE with NGINX Ingress, this is easy.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app
  annotations:
    nginx.ingress.kubernetes.io/server-snippet: |
      add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - app.example.com
      secretName: app-tls
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app-service
                port:
                  number: 80

The always flag matters. Without it, some responses may miss the header.

GCE VM with NGINX

For Compute Engine VMs running NGINX:

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate /etc/ssl/certs/fullchain.pem;
    ssl_certificate_key /etc/ssl/private/privkey.pem;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
    }
}

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

GCE VM with Apache

<VirtualHost *:443>
    ServerName example.com

    Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"

    ProxyPreserveHost On
    ProxyPass / http://127.0.0.1:3000/
    ProxyPassReverse / http://127.0.0.1:3000/
</VirtualHost>

<VirtualHost *:80>
    ServerName example.com
    Redirect permanent / https://example.com/
</VirtualHost>

Make sure the required modules are enabled:

a2enmod headers
a2enmod proxy
a2enmod proxy_http

Common mistakes

Sending HSTS on HTTP

Does nothing. Browsers ignore it.

Setting different HSTS values at multiple layers

App says 1 day, edge says 1 year, old CDN rule says 6 months. Now nobody knows what users cached. Keep it simple.

Using includeSubDomains too early

That old dev. or status. hostname will come back to haunt you.

Preloading before you’re ready

Preload is not a fun rollback story.

Forgetting redirects

HSTS helps after the browser has seen the policy. The first visit still needs a proper HTTPS redirect unless the domain is preloaded.


How to verify

Use curl against the HTTPS endpoint:

curl -I https://example.com

You want to see:

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

Check the HTTP redirect too:

curl -I http://example.com

You want a 301 or 308 redirect to HTTPS.

For a broader header check, scan the site with headertest.com.


Official documentation

Azure:

GCP:

If I had to boil this down to one opinionated rule: set HSTS at the edge when you can, set it in the app when you must, and roll it out like you expect one forgotten subdomain to ruin your day.