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 ruleincludeSubDomains— apply the rule to all subdomainspreload— 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
-
Only send HSTS over HTTPS Browsers ignore it over HTTP anyway, but your app should not emit it on insecure responses.
-
Don’t enable
includeSubDomainsunless every subdomain is HTTPS-ready That includes old dashboards, mail hosts, staging names, and random CNAMEs someone set up three years ago. -
Be careful with
preloadPreload is sticky. Removing a preloaded domain takes time and browser release cycles. -
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-ageof at least 31536000includeSubDomainspreload
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.