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 year
  • includeSubDomains: 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.com
  • devbox.example.com
  • mail.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.com
  • v2.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:

  1. Redirect HTTP to HTTPS
  2. 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.

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=31536000
  • includeSubDomains only after checking your DNS mess honestly
  • no preload until 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.