A few years ago I helped clean up a small Rust service that had all the usual “we’ll fix it before launch” security leftovers.
The app used Rocket. TLS was terminated at a reverse proxy. Redirects from HTTP to HTTPS were working. Cookies were marked Secure. Everyone on the team assumed transport security was done.
It wasn’t.
The missing piece was HSTS: Strict-Transport-Security. Without it, first requests were still vulnerable to downgrade tricks, bad links, stale bookmarks, and users typing example.com without the scheme. The site looked secure in normal testing, but the browser had no instruction to always use HTTPS on future visits.
This is the kind of issue that hides in plain sight because the app “works” until someone tests the right failure mode.
The setup
The stack looked like this:
- Rust app with Rocket
- Nginx terminating TLS
- Internal traffic from proxy to Rocket over private network
- HTTP listener redirecting to HTTPS
- Sessions in secure cookies
The app served authenticated dashboards and account settings. Nothing unusually sensitive by fintech standards, but sensitive enough that transport downgrade risks were unacceptable.
Here’s what the team had in Rocket before fixing headers:
#[macro_use] extern crate rocket;
use rocket::response::content::RawHtml;
#[get("/")]
fn index() -> RawHtml<&'static str> {
RawHtml("<h1>Dashboard</h1>")
}
#[launch]
fn rocket() -> _ {
rocket::build()
.mount("/", routes![index])
}
And the reverse proxy handled redirects like this:
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
At first glance, this is fine. It’s common. It’s also incomplete.
What was wrong
The weakness was simple:
- User visits
http://app.example.comor just typesapp.example.com - Browser makes an insecure request first
- Proxy redirects to HTTPS
- Browser follows redirect
That redirect is too late if an attacker can interfere with the first request. On public Wi-Fi, a hostile network, or a badly configured enterprise proxy, an attacker can suppress or alter the redirect. HSTS tells the browser: “Stop trying HTTP. Use HTTPS only for this host for the next N seconds.”
No HSTS means HTTPS is preferred, not enforced by the browser.
When we scanned the site, the missing header was obvious. Any decent header scanner catches it quickly. If you want a quick read on your own setup, run a scan at headertest.com?utm_source=hsts-guide&utm_medium=blog&utm_campaign=article-link.
The “before” response
A typical response looked like this:
HTTP/2 200
content-type: text/html; charset=utf-8
set-cookie: session=abc123; Secure; HttpOnly; SameSite=Lax
x-content-type-options: nosniff
x-frame-options: DENY
What was missing:
strict-transport-security: max-age=31536000; includeSubDomains
That one line changes browser behavior in a meaningful way.
Where to set HSTS in a Rocket app
You have two realistic options:
- Set HSTS in the reverse proxy
- Set HSTS in Rocket
If your proxy terminates TLS, I usually prefer setting it there because it guarantees every HTTPS response gets the header, including static files, error pages, and non-Rocket upstreams.
But teams often want app-level ownership, especially when infrastructure is shared or hard to change. Rocket makes this straightforward with a fairing.
The “after” fix in Rocket
Here’s a simple fairing that adds HSTS to every response:
#[macro_use] extern crate rocket;
use rocket::{
fairing::{Fairing, Info, Kind},
http::Header,
Request, Response,
};
pub struct Hsts;
#[rocket::async_trait]
impl Fairing for Hsts {
fn info(&self) -> Info {
Info {
name: "Add HSTS header",
kind: Kind::Response,
}
}
async fn on_response<'r>(&self, _req: &'r Request<'_>, res: &mut Response<'r>) {
res.set_header(Header::new(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
));
}
}
#[get("/")]
fn index() -> &'static str {
"Dashboard"
}
#[launch]
fn rocket() -> _ {
rocket::build()
.attach(Hsts)
.mount("/", routes![index])
}
That’s the core fix. Rocket’s fairing system is a good fit here because you want one policy applied consistently.
Rocket documentation for fairings is here: https://rocket.rs
The production version we actually shipped
The first draft above works, but real deployments need a bit more care.
We only wanted to emit HSTS when the original request was HTTPS. Since TLS was terminated upstream, Rocket itself only saw proxied traffic. That meant trusting X-Forwarded-Proto from our proxy and making sure Rocket wasn’t directly exposed.
Here’s the version we shipped:
#[macro_use] extern crate rocket;
use rocket::{
fairing::{Fairing, Info, Kind},
http::Header,
Request, Response,
};
pub struct Hsts {
value: &'static str,
}
impl Hsts {
pub fn new(value: &'static str) -> Self {
Self { value }
}
}
#[rocket::async_trait]
impl Fairing for Hsts {
fn info(&self) -> Info {
Info {
name: "Conditional HSTS header",
kind: Kind::Response,
}
}
async fn on_response<'r>(&self, req: &'r Request<'_>, res: &mut Response<'r>) {
let forwarded_proto = req.headers().get_one("X-Forwarded-Proto");
if matches!(forwarded_proto, Some("https")) {
res.set_header(Header::new(
"Strict-Transport-Security",
self.value,
));
}
}
}
#[get("/")]
fn index() -> &'static str {
"Dashboard"
}
#[launch]
fn rocket() -> _ {
rocket::build()
.attach(Hsts::new("max-age=31536000; includeSubDomains"))
.mount("/", routes![index])
}
This avoided sending HSTS on accidental plain HTTP traffic in internal environments.
That said, be honest about your trust boundary. If clients can hit Rocket directly and spoof X-Forwarded-Proto, this pattern is sloppy. Lock the app down so only your proxy can reach it.
What changed after rollout
Before HSTS:
- Browser relied on redirects every time
- First connection could be downgraded
- Security review flagged “HTTPS not enforced by user agent”
- Header scans showed a gap despite TLS being present
After HSTS:
- Returning visitors were pinned to HTTPS by the browser
- Downgrade attempts stopped working after first secure visit
- Security scan improved immediately
- Team stopped arguing that redirect-only was “good enough”
The response became:
HTTP/2 200
content-type: text/html; charset=utf-8
set-cookie: session=abc123; Secure; HttpOnly; SameSite=Lax
x-content-type-options: nosniff
x-frame-options: DENY
strict-transport-security: max-age=31536000; includeSubDomains
That’s a real security improvement, not just compliance theater.
The part teams get wrong: rollout
HSTS is easy to enable and annoying to undo. That’s why I never start with a one-year policy on day one unless I’m absolutely sure the domain and all subdomains are clean.
A safer rollout looks like this:
Phase 1: short max-age
Strict-Transport-Security: max-age=300
Five minutes. Enough to confirm behavior without locking users in for long.
Phase 2: increase to days or weeks
Strict-Transport-Security: max-age=86400
Then maybe:
Strict-Transport-Security: max-age=2592000
Phase 3: long-lived policy
Strict-Transport-Security: max-age=31536000; includeSubDomains
Only add includeSubDomains when you’re sure every subdomain is HTTPS-ready. I’ve seen teams break legacy admin hosts and forgotten mail-related subdomains by enabling it too early.
Should you use preload?
Maybe, but not casually.
You’ll see policies like this:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
preload is for submitting your domain to browser preload lists. That’s a stronger commitment, and it’s harder to reverse than normal HSTS. If your domain portfolio is messy, don’t do it yet.
I like preload for mature environments with tight domain control. I don’t like it for startups with mystery subdomains and old staging hosts.
One subtle gotcha with local development
Don’t train your browser to force HTTPS on local domains you use for development unless you really mean it. HSTS sticks. People set aggressive HSTS on dev.example.test, forget about it, and then spend an hour wondering why local HTTP stopped working.
Keep production HSTS policies in production config. Be deliberate in staging.
If you prefer proxy-level HSTS
If Nginx owns TLS, this is often the cleanest fix:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
The always matters because you want the header even on error responses.
I still like having header tests in CI or deployment checks because config drift happens. Someone edits the proxy, a container image changes, or a staging template leaks into production. Then HSTS disappears quietly.
What I’d ship today
For a normal Rocket app behind a trusted HTTPS reverse proxy:
- Redirect HTTP to HTTPS at the proxy
- Set HSTS at the proxy if possible
- If app-managed, use a Rocket fairing
- Roll out with a short
max-age - Add
includeSubDomainsonly after auditing subdomains - Treat
preloadas a commitment, not a checkbox
And I’d verify the live result with a header scan instead of trusting my eyes. Browsers cache HSTS, proxies behave differently across environments, and “it worked on staging” means nothing here. A quick pass at headertest.com?utm_source=hsts-guide&utm_medium=blog&utm_campaign=article-link catches the obvious mistakes fast.
HSTS is one of those controls that feels boring right up until you realize your HTTPS story depended on a redirect and good luck. In Rocket, the fix is small. The impact is not.