HTTP Strict Transport Security is one of those headers you set once, then hopefully never think about again. That’s also why people get it wrong.

For Express apps, HSTS looks deceptively simple: add a header and move on. But the real-world version has edge cases around reverse proxies, local development, subdomains, preload, and rollbacks. If you set preload too early, you can create a painful cleanup project for yourself.

Here’s the practical guide I wish more teams followed.

What HSTS does

HSTS tells browsers:

  • only use HTTPS for this site
  • automatically upgrade future HTTP requests to HTTPS
  • refuse to click through TLS certificate warnings while the policy is active

A typical header looks like this:

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

That means:

  • max-age=31536000: remember this rule for 1 year
  • includeSubDomains: apply it to all subdomains too

Optional:

  • preload: asks browser vendors to hardcode your domain into preload lists

When to use HSTS

Use HSTS if:

  • your site is fully HTTPS
  • HTTP requests are redirected to HTTPS
  • every subdomain covered by the policy supports HTTPS if you use includeSubDomains
  • you understand the risk of preload

Don’t use HSTS on plain HTTP-only sites. Browsers ignore the header over HTTP anyway.

The simplest Express setup

If your Express app terminates TLS itself, you can set the header directly:

const express = require('express');
const app = express();

app.use((req, res, next) => {
  res.setHeader(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains'
  );
  next();
});

app.get('/', (req, res) => {
  res.send('hello');
});

app.listen(3000);

That works, but most production Express apps sit behind Nginx, a cloud load balancer, or a platform proxy. In those setups, you need to be more careful.

The better option: use Helmet

If you’re on Express, I’d use Helmet’s official documentation as the base reference and let the middleware handle the header.

Install it:

npm install helmet

Then configure HSTS:

const express = require('express');
const helmet = require('helmet');

const app = express();

app.use(
  helmet({
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: false,
    },
  })
);

app.get('/', (req, res) => {
  res.send('hello');
});

app.listen(3000);

That sends:

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

If you want preload, set it explicitly:

app.use(
  helmet({
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: true,
    },
  })
);

That becomes:

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

Safe production config I’d actually ship

For most teams, this is the baseline:

const express = require('express');
const helmet = require('helmet');

const app = express();

app.disable('x-powered-by');

app.use(
  helmet({
    hsts: {
      maxAge: 31536000, // 1 year
      includeSubDomains: true,
      preload: false, // don't turn this on casually
    },
  })
);

app.get('/health', (req, res) => {
  res.status(200).send('ok');
});

app.get('/', (req, res) => {
  res.send('secure app');
});

app.listen(3000);

Why this config:

  • 1 year is a normal production value
  • includeSubDomains avoids weird weaker subdomain behavior
  • preload: false until you’ve audited everything

Only send HSTS over HTTPS

Browsers only honor HSTS when received over HTTPS. Still, I prefer making the app logic explicit, especially behind proxies.

If Express is behind a reverse proxy, first trust the proxy:

app.set('trust proxy', 1);

Then send HSTS only on secure requests:

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 app = express();

app.set('trust proxy', 1);

app.use((req, res, next) => {
  if (req.secure) {
    res.setHeader(
      'Strict-Transport-Security',
      'max-age=31536000; includeSubDomains'
    );
  }
  next();
});

app.get('/', (req, res) => {
  res.send('hello');
});

app.listen(3000);

Without trust proxy, req.secure may be false even when the original client connected over HTTPS to your load balancer.

Redirect HTTP to HTTPS first

HSTS is not a replacement for redirects. The first visit may still happen over HTTP, and HSTS only helps after the browser learns the policy, unless you’re preloaded.

A common Express pattern behind a proxy:

const express = require('express');
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((req, res, next) => {
  res.setHeader(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains'
  );
  next();
});

app.get('/', (req, res) => {
  res.send('HTTPS only');
});

app.listen(3000);

That’s the right order:

  1. redirect insecure traffic
  2. serve HSTS on secure responses

Development setup: usually disable HSTS

Don’t casually set long-lived HSTS on localhost or dev domains you reuse for testing. I’ve seen people lock themselves into weird browser behavior and then waste an afternoon debugging “why won’t Chrome stop forcing HTTPS?”

A practical pattern:

const express = require('express');
const helmet = require('helmet');

const app = express();
const isProduction = process.env.NODE_ENV === 'production';

if (isProduction) {
  app.use(
    helmet({
      hsts: {
        maxAge: 31536000,
        includeSubDomains: true,
        preload: false,
      },
    })
  );
}

app.get('/', (req, res) => {
  res.send(isProduction ? 'prod' : 'dev');
});

app.listen(3000);

If you need HSTS in staging, use a short max-age first:

app.use(
  helmet({
    hsts: {
      maxAge: 300, // 5 minutes
      includeSubDomains: false,
      preload: false,
    },
  })
);

That’s a much safer way to test rollout behavior.

Picking a max-age

I’d treat it like a staged rollout:

Stage 1: test safely

Strict-Transport-Security: max-age=300

5 minutes.

Stage 2: increase confidence

Strict-Transport-Security: max-age=86400

1 day.

Stage 3: production

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

1 year.

That gives you room to catch certificate issues, forgotten subdomains, old services, and staging weirdness.

includeSubDomains: great until it isn’t

I usually want includeSubDomains, but only after checking reality, not the architecture diagram.

If you send:

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

then all of these need to be HTTPS-capable:

  • www.example.com
  • api.example.com
  • admin.example.com
  • old-tool.example.com
  • anything else under the zone

The problem is always the forgotten subdomain. Some abandoned dashboard, mail host, legacy upload box, or vendor-managed service suddenly matters.

If you’re not sure, start without it:

Strict-Transport-Security: max-age=31536000

Then add includeSubDomains after an audit.

preload: treat it like a one-way door

Preload gets your domain baked into browser lists. That closes the “first request over HTTP” gap, which is nice. It also makes rollbacks much slower and more annoying.

Requirements generally include:

  • valid HTTPS on the main domain
  • HTTPS redirect from HTTP
  • HSTS with:
    • max-age of at least 1 year
    • includeSubDomains
    • preload

Header example:

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

Express with Helmet:

app.use(
  helmet({
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: true,
    },
  })
);

I would not enable preload unless:

  • every current subdomain supports HTTPS
  • future subdomain creation is controlled
  • the team understands this is sticky

How to remove or reduce HSTS

If you need to turn it down, send a smaller policy or clear it with:

Strict-Transport-Security: max-age=0

Express example:

app.use((req, res, next) => {
  if (req.secure) {
    res.setHeader('Strict-Transport-Security', 'max-age=0');
  }
  next();
});

That tells browsers to forget the cached HSTS policy for normal HSTS. It does not instantly solve preload if your domain was added to browser preload lists.

Verify your header

Check the response headers directly:

curl -I https://yourdomain.com

Expected output includes something like:

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

If you’re behind a CDN or proxy, verify the public edge response, not just your app server.

You can also run a quick scan with HeaderTest to confirm HSTS and the rest of your security headers.

Common mistakes

Sending HSTS on HTTP and assuming it works

Browsers ignore HSTS received over plain HTTP.

Enabling includeSubDomains too early

This is the classic foot-gun.

Enabling preload because it “sounds more secure”

It is more strict, not automatically more appropriate.

Forgetting trust proxy

If TLS terminates before Express, req.secure may not behave how you expect unless proxy trust is configured. Express documents this in the official guide.

Testing on a real domain with a long max-age

You can poison your own browser cache for a long time and make debugging miserable.

Minimal production setup with Helmet

const express = require('express');
const helmet = require('helmet');

const app = express();

app.use(
  helmet({
    hsts: {
      maxAge: 31536000,
      includeSubDomains: true,
      preload: false,
    },
  })
);

app.listen(3000);

Reverse proxy setup with redirect

const express = require('express');
const helmet = require('helmet');
const express = require('express');

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.listen(3000);

There’s a typo people copy around a lot when building examples by hand, so here’s the corrected version with one express import:

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.listen(3000);

Staged rollout setup

const express = require('express');
const helmet = require('helmet');

const app = express();

const hstsMaxAge = process.env.HSTS_MAX_AGE
  ? parseInt(process.env.HSTS_MAX_AGE, 10)
  : 300;

app.use(
  helmet({
    hsts: {
      maxAge: hstsMaxAge,
      includeSubDomains: false,
      preload: false,
    },
  })
);

app.listen(3000);

Then promote over time:

HSTS_MAX_AGE=300
HSTS_MAX_AGE=86400
HSTS_MAX_AGE=31536000

My default advice: enable HSTS, use Helmet, start with a shorter max-age, audit subdomains before includeSubDomains, and stay away from preload until you’re sure you want that commitment. That’s the version that keeps security strong without turning a one-line header into an ops incident.