HSTS is one of those headers that’s easy to enable and surprisingly easy to get wrong.
If you run a REST API with Express, HSTS tells clients: “Stop trying plain HTTP for this host. Use HTTPS only for a while.” That sounds simple, but the details matter a lot in production, especially behind proxies, load balancers, and CDNs.
This guide is the version I wish more API teams used: what to send, when to send it, and what not to do.
What HSTS does
The Strict-Transport-Security response header tells browsers that your domain must only be accessed over HTTPS for a defined period.
Example:
Strict-Transport-Security: max-age=31536000; includeSubDomains
That means:
max-age=31536000: remember HTTPS-only for 1 yearincludeSubDomains: apply the rule to all subdomains too
Once a browser sees this header over a valid HTTPS connection, it will automatically rewrite future http:// requests to https:// before making the request.
That helps against downgrade attacks and users typing the wrong URL.
Does HSTS matter for REST APIs?
Yes, with one caveat: HSTS is primarily enforced by browsers.
If your API is called by:
- browser-based SPAs
- frontend apps using
fetch - browser clients hitting your API directly
- developer portals and docs in the browser
then HSTS absolutely matters.
If your API is only consumed server-to-server by backend services, HSTS won’t protect those non-browser clients unless they implement it themselves. You still want HTTPS, redirects, and proper TLS config, but HSTS won’t magically secure every client.
My rule: if a browser can hit it, send HSTS.
Express setup: the easy way with Helmet
If you already use Helmet official docs, use its HSTS middleware.
Install it:
npm install helmet
Basic setup:
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(helmet.hsts({
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true,
preload: false
}));
app.get('/health', (req, res) => {
res.json({ ok: true });
});
app.listen(3000);
That sends:
Strict-Transport-Security: max-age=31536000; includeSubDomains
If you want preload eligibility:
app.use(helmet.hsts({
maxAge: 31536000,
includeSubDomains: true,
preload: true
}));
That sends:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Manual Express setup without Helmet
If you don’t want Helmet for some reason, set the header yourself.
const express = require('express');
const app = express();
app.use((req, res, next) => {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
next();
});
app.get('/health', (req, res) => {
res.json({ ok: true });
});
app.listen(3000);
That works, but I generally prefer Helmet because it reduces tiny mistakes and keeps intent obvious.
Only send HSTS over HTTPS
Browsers ignore HSTS sent over plain HTTP. That’s by design.
So this is wrong if your app serves both HTTP and HTTPS directly:
app.use((req, res, next) => {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
next();
});
Instead, only set it for secure requests.
If Express terminates TLS itself
app.use((req, res, next) => {
if (req.secure) {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
}
next();
});
If Express is behind a reverse proxy
This is where people get burned.
If TLS is terminated at Nginx, ALB, Cloudflare, or another proxy, Express won’t see the connection as secure unless you trust the proxy.
Set this first:
app.set('trust proxy', 1);
Then:
app.use((req, res, next) => {
if (req.secure) {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
}
next();
});
Full example:
const express = require('express');
const helmet = require('helmet');
const app = express();
app.set('trust proxy', 1);
app.use((req, res, next) => {
if (!req.secure) {
return res.redirect(301, `https://${req.headers.host}${req.originalUrl}`);
}
next();
});
app.use(helmet.hsts({
maxAge: 31536000,
includeSubDomains: true,
preload: false
}));
app.get('/v1/users', (req, res) => {
res.json([{ id: 1, name: 'Ada' }]);
});
app.listen(3000);
If you forget trust proxy, req.secure may stay false and your redirect logic can loop or your HSTS logic won’t run correctly.
Good HSTS values for APIs
Here’s the practical version.
Safe starting rollout
Start small if you’re not fully sure every path and subdomain is HTTPS-ready:
Strict-Transport-Security: max-age=300
That’s 5 minutes. Good for testing.
Then increase gradually:
Strict-Transport-Security: max-age=86400
1 day.
Then move to the standard long-lived policy:
Strict-Transport-Security: max-age=31536000; includeSubDomains
1 year.
Production recommendation
For a stable production API on a dedicated HTTPS-only domain:
Strict-Transport-Security: max-age=31536000; includeSubDomains
Preload-ready policy
If you want preload eligibility:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
But don’t add preload casually. That’s a commitment.
When to use includeSubDomains
Use it only if every subdomain is HTTPS-capable and intended to stay that way.
If your API lives at:
api.example.com
and you also own random legacy hosts like:
old-admin.example.comdevbox.example.commail.example.com
then includeSubDomains can break things if any of them still need HTTP or have broken TLS.
A safer pattern is to host APIs on a dedicated domain tree, like:
api.example.comv2.api.example.com
Then applying includeSubDomains is easier to reason about.
When to use preload
Preload gets your domain baked into browser preload lists so browsers know to use HTTPS before the first request.
That’s strong protection, but operationally strict.
You should only use preload if:
- your entire domain strategy is HTTPS-only
- all subdomains are covered if you use
includeSubDomains - you can maintain this long-term
- you understand removal is slow and annoying
A preload-ready Express setup:
const express = require('express');
const helmet = require('helmet');
const app = express();
app.set('trust proxy', 1);
app.use((req, res, next) => {
if (!req.secure) {
return res.redirect(301, `https://${req.headers.host}${req.originalUrl}`);
}
next();
});
app.use(helmet.hsts({
maxAge: 31536000,
includeSubDomains: true,
preload: true
}));
app.get('/v1/orders', (req, res) => {
res.json([{ id: 'ord_123', total: 4999 }]);
});
app.listen(3000);
If you’re not sure, skip preload for now.
HSTS and API redirects
A clean API deployment usually does two things:
- Redirect HTTP to HTTPS
- Send HSTS on HTTPS responses
Example:
app.set('trust proxy', 1);
app.use((req, res, next) => {
if (!req.secure) {
return res.redirect(301, `https://${req.headers.host}${req.originalUrl}`);
}
next();
});
app.use((req, res, next) => {
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
next();
});
That first redirect is still vulnerable on the very first visit unless preload already covers the domain. HSTS protects subsequent visits after the browser learns the policy.
Common mistakes
1. Sending HSTS on localhost
Don’t do this in local dev. It creates annoying browser behavior.
A common pattern:
if (process.env.NODE_ENV === 'production') {
app.use(helmet.hsts({
maxAge: 31536000,
includeSubDomains: true
}));
}
2. Using a huge max-age too early
Don’t start with a year if you haven’t tested your domain and subdomains properly.
Start with 5 minutes or 1 day.
3. Breaking subdomains with includeSubDomains
This is probably the most expensive mistake. Inventory your subdomains first.
4. Assuming HSTS protects non-browser API clients
It mostly doesn’t. Your mobile SDK, backend jobs, and third-party integrations still need proper HTTPS enforcement and certificate validation.
5. Forgetting proxy configuration
If you’re behind a proxy and don’t configure trust proxy, secure detection can fail.
Recommended production template
If you want a sane default for an Express REST API behind a proxy:
const express = require('express');
const helmet = require('helmet');
const app = express();
app.set('trust proxy', 1);
app.use((req, res, next) => {
if (!req.secure) {
return res.redirect(301, `https://${req.headers.host}${req.originalUrl}`);
}
next();
});
app.use(helmet({
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: false
}
}));
app.get('/v1/health', (req, res) => {
res.json({ status: 'ok' });
});
app.get('/v1/profile', (req, res) => {
res.json({ id: 42, email: '[email protected]' });
});
app.listen(3000, () => {
console.log('API listening on port 3000');
});
How to verify it
Use curl against the HTTPS endpoint:
curl -I https://api.example.com/v1/health
You want to see something like:
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000; includeSubDomains
Check the HTTP redirect too:
curl -I http://api.example.com/v1/health
Expected:
HTTP/1.1 301 Moved Permanently
Location: https://api.example.com/v1/health
If you want a quick header sanity check, run a scan at headertest.com.
Quick reference
Minimal HSTS header
Strict-Transport-Security: max-age=31536000
Better for stable production
Strict-Transport-Security: max-age=31536000; includeSubDomains
Preload-ready
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Express with Helmet
app.use(helmet.hsts({
maxAge: 31536000,
includeSubDomains: true,
preload: false
}));
Express behind proxy
app.set('trust proxy', 1);
Final opinion
For most Express REST APIs, I’d ship this:
- HTTP redirects to HTTPS
- HSTS enabled in production only
max-age=31536000includeSubDomainsonly after checking your DNS mess honestly- no
preloaduntil you’re absolutely sure
HSTS is cheap, effective, and easy to keep once it’s set up right. The hard part isn’t Express. The hard part is being honest about the rest of your infrastructure.
For the official Express middleware docs, check Helmet official docs. For header behavior and syntax, the canonical reference is the MDN Strict-Transport-Security documentation.