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.

Anatomy of the Strict-Transport-Security headerThe header name and its two directives split into labeled parts showing the lifetime value in seconds and the subdomain scope flag. Strict-Transport-Security: max-age=63072000; includeSubDomains header name max-age=63072000 directive 1 includeSubDomains directive 2 names the policy the browser stores lifetime in seconds, reset on each response 63072000 = 2 years applies policy to every host below the apex valueless flag Order-independent, case-insensitive, and the value must be unquoted
The wire value is three tokens: a header name, a numeric lifetime the browser counts down in seconds, and a flag that widens scope to every host under the apex.

Because max-age is expressed in raw seconds, the common milestones are worth memorizing: 300 (five minutes) for a first staging probe, 86400 (one day), 2592000 (thirty days), 31536000 (one year, the preload floor), and 63072000 (two years, the hardening target). A leading zero, a decimal, or thousands separators are all invalid; the field must be a bare non-negative integer. If a client receives a header whose max-age cannot be parsed, it ignores the entire Strict-Transport-Security directive for that response rather than falling back to a default — a silent failure that only surfaces when you inspect the stored entry in chrome://net-internals/#hsts.

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.

How includeSubDomains Expands Scope

Without the flag, an HSTS policy set on example.com protects only that exact host; api.example.com and login.example.com remain independently downgradeable and can still be stripped to plaintext by an on-path attacker. includeSubDomains collapses that gap by binding the policy to the entire subtree. Critically, the browser applies the policy to hosts it has never contacted: the first time a user is lured to victim-lookalike.example.com, the cached apex policy already forces HTTPS, so a forged plaintext redirect never leaves the browser.

That reach is exactly why the flag is dangerous to enable blind. Enforcement is keyed on the registered name, not on which certificate is actually presented, so any subtree host whose TLS is missing, self-signed, or scoped to the wrong Common Name becomes an instant, non-bypassable failure. A wildcard *.example.com certificate covers only one label of depth — a.example.com but not a.b.example.com — so deep hierarchies need either a multi-level wildcard strategy or per-host certificates before the flag is safe.

includeSubDomains enforcement scope treeAn apex policy cascades HTTPS enforcement onto every subdomain including never-visited hosts, while a subdomain without a valid certificate becomes a non-bypassable lockout. example.com HSTS + includeSubDomains api.example.com valid cert HTTPS enforced cdn.example.com valid cert HTTPS enforced internal.example.com cert mismatch lockout, no bypass old.example.com never visited still enforced One apex policy governs the whole subtree, whether or not the browser has seen the host A single missing or mismatched certificate turns coverage into an outage
Enforcement follows the registered name, not the certificate: every subtree host is covered, so one host with broken TLS becomes a non-bypassable lockout the moment the apex policy is cached.

There is a second, quieter consequence: includeSubDomains interacts with cookie scoping. A Secure cookie set on the apex is now guaranteed to travel only over HTTPS to every subdomain, closing the classic cookie-injection path where an attacker forces a plaintext subdomain request to plant or read a session cookie. That benefit is only real once the flag is stable, which is why the audit below runs against every host in the zone, not just the ones in active use.

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.

Batch-check every host before enabling includeSubDomains. A single failing subdomain is enough to lock users out, so drive the certificate check across the whole zone rather than spot-checking:

for h in api cdn internal monitoring old www; do
  printf '%s: ' "$h.yourdomain.com"
  echo | openssl s_client -connect "$h.yourdomain.com:443" \
    -servername "$h.yourdomain.com" 2>/dev/null \
    | grep -m1 'Verify return code' || echo 'UNREACHABLE'
done

Expected output: Verify return code: 0 (ok) on every line. Any UNREACHABLE, timeout, or non-zero code is a host that will break under includeSubDomains — resolve it before you raise the scope.

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; the returned JSON echoes static_upgrade_mode, dynamic_sts_include_subdomains, and the stored expiry, which lets you confirm the browser actually recorded the scope and lifetime you sent rather than a stale earlier policy.

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.

When a lockout does happen, the only server-side lever is to keep the broken host reachable over valid HTTPS and serve Strict-Transport-Security: max-age=0 from it so revisiting browsers drop the cached entry — but this reaches a client only on its next successful HTTPS handshake, which is precisely what a certificate failure prevents. That circular dependency is why the fix must be a working certificate on the failing host, not a header change: restore TLS first, and the max-age=0 response can then land. There is no way to push a reset to a browser that will not complete the handshake, and affected users cannot click through, so the practical blast radius of a bad two-year policy is every visitor until their local cache expires.

The safe path is therefore to ramp max-age in stages, validating subdomain reachability at each step before the value grows long enough to be costly to undo. Keep the low-value stages long enough to catch real-world traffic — a 300-second policy in staging tells you nothing if no one exercises the subdomains during that window — and only commit the two-year value once a full business cycle has passed without a certificate or reachability incident.

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.