How to Configure max-age and includeSubDomains for HSTS

This guide sets the two directives that control HSTS scope and lifetime. The protocol mechanics, RFC 6797 threat model, and preload trade-offs behind these values live in the HTTP Strict Transport Security (HSTS) deep dive; here the focus is the exact wire values and the safe path to a one-year policy.

Configuration Syntax & Exact Values

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

Both directives are case-insensitive and order-independent. Do not quote the value inside the directive (max-age="63072000" is malformed and silently dropped). Add preload only when you have committed to the near-permanent enforcement trade-offs and reached the full two-year value.

Server-Side Configuration

Emit the header once, at the outermost layer that touches every response. Declaring it at both the CDN and the origin produces two copies; pick one source.

Nginx

add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

always forces emission on 4xx/5xx responses; without it the header vanishes from error pages. An add_header in a location block replaces inherited headers, so repeat this line in any location that sets its own.

Apache

Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"

always appends the header even on internally generated error documents, which the default onsuccess table skips. Requires mod_headers (a2enmod headers).

Cloudflare

Dashboard → SSL/TLS → Edge Certificates → HTTP Strict Transport Security (HSTS). Enable it, set Max-Age to 12 months or longer, and toggle Include subdomains to On. Cloudflare emits the header at the edge on all proxied responses; delete any duplicate origin add_header so only one copy reaches the browser.

Node/Helmet

const helmet = require('helmet');
app.use(helmet.hsts({ maxAge: 63072000, includeSubDomains: true }));

Register Helmet before route and error handlers so short-circuited responses still carry the header. Behind a TLS-terminating proxy, set app.set('trust proxy', 1) so Express treats forwarded requests as secure.

Diagnostic & Verification Steps

Confirm the exact wire value:

curl -sI https://yourdomain.com | grep -i strict-transport-security

Expected output:

strict-transport-security: max-age=63072000; includeSubDomains

Verify subdomain coverage before trusting includeSubDomains — the flag enforces on hosts even if their certificate does not cover them:

openssl s_client -connect api.yourdomain.com:443 -servername api.yourdomain.com < /dev/null 2>/dev/null \
  | grep -E 'Verify return code|subject='

Expected output: Verify return code: 0 (ok) on every subdomain you intend to cover.

Browser DevTools: Network tab → filter Doc → select the document request → Response Headers → confirm the exact value and that no duplicate Strict-Transport-Security line is present. Cross-check the cached entry at chrome://net-internals/#hsts via Query HSTS/PKP domain.

Edge Cases, Security Implications & Safe Rollback

A misconfigured subdomain surfaces as NET::ERR_CERT_COMMON_NAME_INVALID with no proceed button — HSTS errors are non-bypassable by design. Three traps dominate:

  1. Subdomain certificate gaps. includeSubDomains enforces HTTPS on internal., monitoring., and legacy API hosts whether or not their certificate matches. Audit every host (subfinder + httpx, or your DNS zone export) before enabling the flag.
  2. Cache stickiness on rollback. Reducing max-age does not clear existing browser caches. To revert you must actively serve Strict-Transport-Security: max-age=0; includeSubDomains over HTTPS, and it only reaches clients that revisit before their existing max-age expires. A botched two-year policy can lock a user out for two years with no server-side fix.
  3. Preload lock-in. Submitting to the preload list is effectively permanent; removal takes months of browser release cycles. Never preload until includeSubDomains is proven across staging, legacy, and development hosts.

The safe path is to ramp max-age in stages, validating subdomain reachability at each step before the value grows long enough to be costly to undo.

Staged max-age ramp timeline A timeline stepping max-age from 300 seconds in staging, to one day, to thirty days, to two years with preload in production, validating subdomains at each stage. max-age=300 5 min staging max-age=86400 1 day canary max-age=2592000 30 days production max-age=63072000 2 yr + preload commit Validate every subdomain over HTTPS at each step
Ramp max-age only after confirming subdomain TLS at the current step; the long value and preload come last because they are the hardest to reverse.

Frequently Asked Questions

What max-age value should I use in production? 63072000 (two years) is the recommended hardening value and exceeds the one-year (31536000) preload floor. Anything shorter than one year disqualifies the domain from preloading.

Will increasing max-age reset the timer for returning visitors? Yes. Browsers recompute expiry from each response, so a visitor who hits a longer value adopts the new lifetime immediately. There is no need to wait for the old value to lapse before raising it.

Can I roll back just by lowering max-age? No. Lowering the value affects only future responses; cached policies persist until their original max-age expires. Active rollback requires serving max-age=0 over HTTPS and waiting for clients to revisit.

Is includeSubDomains safe to add immediately? Only after auditing TLS on every subdomain. The flag enforces HTTPS on hosts the browser has never seen, and any host without a matching certificate produces a non-bypassable error.

Conclusion

Start at max-age=300 in staging to prove every subdomain — internal tooling, dashboards, APIs — answers over HTTPS, then ramp to 86400, 2592000, and finally 63072000 with includeSubDomains in production. Add preload and submit to hstspreload.org only after the two-year policy has been stable in production, because that step is effectively permanent.