HTTP Strict Transport Security is one of those headers that’s simple, powerful, and easy to get subtly wrong.

If you’re serving a Rust app with Actix-web, HSTS usually comes down to one header:

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

That header tells browsers: “For this domain, always use HTTPS for the next year.” Once a browser sees it over a valid HTTPS connection, it will refuse to use plain HTTP for that site until the policy expires.

That blocks a whole class of downgrade and SSL stripping attacks. It also means you can lock yourself into HTTPS harder than you intended if you ship a bad policy. I’ve seen people turn on includeSubDomains without realizing some forgotten subdomain still serves plain HTTP.

Here’s the practical guide.

The HSTS header format

The header supports three directives you’ll care about:

  • max-age=<seconds> — how long the browser should remember the HTTPS-only rule
  • includeSubDomains — apply the rule to all subdomains
  • preload — opt in to browser preload lists, if you meet preload requirements

Example:

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

What I recommend

For most production apps:

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

If you’re intentionally pursuing preload and you understand the commitment:

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

For initial rollout, especially on an older estate:

Strict-Transport-Security: max-age=300

Five minutes is a safe way to prove you didn’t break users before bumping it to months or a year.

The biggest HSTS rules people forget

  1. Only send HSTS over HTTPS Browsers ignore it over HTTP anyway, but your app should not emit it on insecure responses.

  2. Don’t enable includeSubDomains unless every subdomain is HTTPS-ready That includes old dashboards, mail hosts, staging names, and random CNAMEs someone set up three years ago.

  3. Be careful with preload Preload is sticky. Removing a preloaded domain takes time and browser release cycles.

  4. Local development should usually not use HSTS You can poison your own browser state for localhost-style setups and make debugging annoying.

Basic HSTS in Actix-web

The easiest approach is to add the header with default headers middleware.

use actix_web::{App, HttpServer, middleware::DefaultHeaders, web, HttpResponse};

async fn index() -> HttpResponse {
    HttpResponse::Ok().body("Hello, HTTPS")
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .wrap(
                DefaultHeaders::new()
                    .add(("Strict-Transport-Security", "max-age=31536000; includeSubDomains"))
            )
            .route("/", web::get().to(index))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

This is fine if this app only serves HTTPS traffic behind your edge or directly.

If your app receives both HTTP and HTTPS traffic, or sits behind a proxy where scheme handling matters, you’ll want conditional logic instead of blindly adding the header.

Conditionally send HSTS only for secure requests

This is the safer pattern.

use actix_web::{
    dev::{Service, ServiceRequest, ServiceResponse, Transform},
    http::header::{HeaderName, HeaderValue},
    web, App, Error, HttpResponse, HttpServer,
};
use futures_util::future::{ok, LocalBoxFuture, Ready};
use std::rc::Rc;
use std::task::{Context, Poll};

async fn index() -> HttpResponse {
    HttpResponse::Ok().body("Secure app")
}

pub struct Hsts;

impl<S, B> Transform<S, ServiceRequest> for Hsts
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type InitError = ();
    type Transform = HstsMiddleware<S>;
    type Future = Ready<Result<Self::Transform, Self::InitError>>;

    fn new_transform(&self, service: S) -> Self::Future {
        ok(HstsMiddleware {
            service: Rc::new(service),
        })
    }
}

pub struct HstsMiddleware<S> {
    service: Rc<S>,
}

impl<S, B> Service<ServiceRequest> for HstsMiddleware<S>
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;

    fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.service.poll_ready(ctx)
    }

    fn call(&self, req: ServiceRequest) -> Self::Future {
        let service = self.service.clone();

        Box::pin(async move {
            let is_secure = req.connection_info().scheme() == "https";
            let mut res = service.call(req).await?;

            if is_secure {
                res.headers_mut().insert(
                    HeaderName::from_static("strict-transport-security"),
                    HeaderValue::from_static("max-age=31536000; includeSubDomains"),
                );
            }

            Ok(res)
        })
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .wrap(Hsts)
            .route("/", web::get().to(index))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

That gives you control and avoids sending HSTS on insecure traffic.

Behind Nginx, Traefik, or a load balancer

This is where people get tripped up.

If TLS terminates at a reverse proxy, your Actix app may see plain HTTP unless forwarded headers are configured correctly. Then req.connection_info().scheme() might return "http" even though the user connected with HTTPS.

Two ways to handle it:

Option 1: Set HSTS at the proxy

Honestly, this is often the cleanest place.

Nginx:

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

That works well because the proxy is the thing actually handling TLS.

Option 2: Trust forwarded scheme correctly in app architecture

If you really want the app to own the header, make sure your proxy sends the scheme:

proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;

Then verify your deployment actually lets Actix interpret that the way you expect. Don’t assume. Test the live response headers.

A free scan at headertest.com is a quick sanity check after rollout.

Redirect HTTP to HTTPS first

HSTS does not fix the very first visit unless your domain is preloaded. For a non-preloaded site, the first request can still hit HTTP before the browser learns the policy.

So you still want a redirect from HTTP to HTTPS.

At the proxy layer, that usually looks like:

Nginx:

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

If you run separate Actix listeners, you can also redirect in Rust:

use actix_web::{web, App, HttpRequest, HttpResponse, HttpServer};

async fn redirect_to_https(req: HttpRequest) -> HttpResponse {
    let host = req.connection_info().host().to_string();
    let uri = req.uri().to_string();
    let location = format!("https://{}{}", host, uri);

    HttpResponse::MovedPermanently()
        .insert_header(("Location", location))
        .finish()
}

I still prefer doing this at the edge.

Preload: when to use it

Preload gets your domain baked into browser lists so even the first request is forced to HTTPS.

To qualify, you generally need:

  • HTTPS on the apex domain
  • HTTPS on all subdomains you care about
  • max-age of at least 31536000
  • includeSubDomains
  • preload

Header example:

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

My opinion: only do this if you have strong control over your DNS and subdomains. If your org has a habit of spinning up random subdomains, preload can turn into operational pain.

Environment-based config

You probably want short values in staging and stronger values in production.

use actix_web::{App, HttpServer, middleware::DefaultHeaders, web, HttpResponse};
use std::env;

async fn index() -> HttpResponse {
    HttpResponse::Ok().body("OK")
}

fn hsts_value() -> String {
    match env::var("APP_ENV").as_deref() {
        Ok("production") => "max-age=31536000; includeSubDomains".to_string(),
        Ok("staging") => "max-age=300".to_string(),
        _ => "max-age=0".to_string(),
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let hsts = hsts_value();

    HttpServer::new(move || {
        App::new()
            .wrap(DefaultHeaders::new().add(("Strict-Transport-Security", hsts.clone())))
            .route("/", web::get().to(index))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

That said, I would not use max-age=0 casually in environments that real browsers hit unless you’re intentionally clearing policy.

How to disable HSTS

To tell browsers to forget the policy, send:

Strict-Transport-Security: max-age=0

In Actix-web:

.wrap(
    DefaultHeaders::new()
        .add(("Strict-Transport-Security", "max-age=0"))
)

This only helps for browsers that receive the new header over HTTPS. It does not instantly erase preload status.

Common mistakes

Sending HSTS on localhost

Don’t. Keep local dev boring.

Using includeSubDomains too early

Audit first. Check old subdomains, customer vanity domains, admin panels, and wildcard DNS.

Thinking HSTS replaces redirects

It doesn’t. You still want HTTP to HTTPS redirects.

Setting it in two places with conflicting values

If your proxy says one thing and your app says another, debugging becomes needlessly weird.

Preloading before you’re ready

Once you ask browsers to hardcode your HTTPS-only policy, rollback is slow.

A good production setup

If I were shipping a normal Actix app behind a reverse proxy, I’d usually do this:

  • Redirect HTTP to HTTPS at the proxy
  • Set HSTS at the proxy
  • Use:
Strict-Transport-Security: max-age=31536000; includeSubDomains
  • Wait before adding preload
  • Verify headers in production after every infra change

If I needed the app itself to manage HSTS, I’d use conditional middleware and only emit the header when the request is definitely secure.

That’s really the whole game: send HSTS only on real HTTPS responses, start cautiously, and don’t promise HTTPS for subdomains you don’t control well.