HSTS report-only mode sounds like something browsers should support.

They don’t.

That’s the first thing worth clearing up, because a lot of developers go looking for a Strict-Transport-Security-Report-Only header and assume they just haven’t found the right syntax yet. There is no standardized HSTS report-only header implemented by browsers in the way Content-Security-Policy-Report-Only works.

So if you’re trying to safely “test” HSTS before enforcing it, the real answer is a mix of:

  • understanding what HSTS actually does
  • using short max-age values during rollout
  • using reporting mechanisms around HTTPS failures where available
  • validating your current headers and redirects before increasing enforcement

If you want a quick sanity check on your current headers, run your site through Headertest.

The short version

There is no browser-supported header like this:

Strict-Transport-Security-Report-Only: max-age=31536000

And this also won’t do anything in browsers:

Strict-Transport-Security: max-age=31536000; report-to="default"

Browsers ignore reporting directives for HSTS because HSTS doesn’t define a report-only mode or reporting integration like CSP does.

If somebody tells you to “deploy HSTS in report-only first,” what they usually mean is:

  1. make sure HTTP redirects cleanly to HTTPS
  2. fix mixed content and insecure subresource references
  3. start with a very small HSTS max-age
  4. observe production behavior
  5. gradually increase max-age
  6. only add includeSubDomains and preload when you’re absolutely sure

That’s the operationally safe path.

What HSTS actually enforces

The real header is:

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

This tells the browser:

  • for the next max-age seconds
  • only connect to this site over HTTPS
  • and optionally apply that to all subdomains too

Once a browser has seen that header over a valid HTTPS connection, it rewrites future HTTP requests to HTTPS before the request leaves the browser.

That means HSTS is not just a redirect optimization. It’s a client-side policy cache.

That’s also why a fake report-only mode would be tricky: HSTS changes browser connection behavior, not just document policy evaluation.

Why teams ask for report-only mode

Usually because they’re worried about these rollout risks:

  • legacy subdomains that still serve plain HTTP
  • broken certificates on less-used hosts
  • old internal links pointing to http://
  • third-party integrations hitting HTTP endpoints
  • accidentally bricking subdomains with includeSubDomains
  • getting stuck with a long max-age after a bad deployment

Those are valid concerns. I’ve seen teams break staging, uploads, old admin portals, and random forgotten support tools by turning on includeSubDomains too early.

The fix is not “HSTS report-only.” The fix is a staged rollout.

Safe rollout pattern that replaces report-only mode

Phase 1: Verify HTTPS everywhere

Before setting HSTS, make sure:

  • the apex domain serves HTTPS correctly
  • www serves HTTPS correctly
  • any user-facing subdomains have valid certificates
  • HTTP redirects to HTTPS with a clean 301 or 308
  • no redirect loops
  • no certificate name mismatches

A minimal redirect setup in Nginx:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

And HTTPS with HSTS disabled initially:

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate     /etc/ssl/example/fullchain.pem;
    ssl_certificate_key /etc/ssl/example/privkey.pem;

    root /var/www/html;
    index index.html;
}

Phase 2: Start with a tiny max-age

Use a short duration first. I usually start with 5 minutes or 1 hour, not a year.

Strict-Transport-Security: max-age=300

Or in Nginx:

add_header Strict-Transport-Security "max-age=300" always;

Apache:

Header always set Strict-Transport-Security "max-age=300"

Express.js with Helmet:

import express from "express";
import helmet from "helmet";

const app = express();

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

app.listen(3000);

This gives you real enforcement, but with a very short recovery window if something goes wrong.

Phase 3: Increase gradually

If the short rollout is clean, increase in steps:

Strict-Transport-Security: max-age=86400

Then:

Strict-Transport-Security: max-age=604800

Then:

Strict-Transport-Security: max-age=31536000

That progression is much safer than jumping straight to one year.

Phase 4: Add includeSubDomains only after inventory

This is where people hurt themselves.

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

Do not add includeSubDomains until you’ve accounted for every relevant subdomain you care about. That includes weird old hosts nobody remembers until they stop working.

If you have a mixed environment like this:

  • www.example.com — public site, HTTPS ready
  • api.example.com — HTTPS ready
  • oldcrm.example.com — still broken over HTTPS
  • dev.example.com — self-signed cert
  • status.example.com — third-party managed

Then includeSubDomains is not ready yet.

Phase 5: Consider preload last

Preload is not “extra secure HSTS.” It’s a commitment.

Typical preload-ready header:

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

Before preload, make sure you actually meet the preload requirements and want every browser to hardcode your domain as HTTPS-only. If you later discover a broken subdomain, preload removal is slow and annoying.

Official docs for preload behavior and HSTS are covered by browser vendors and the HTTP specification, including MDN’s HSTS reference and the spec itself:

Not directly from an HSTS report-only mode, because again, that mode does not exist.

What you can do is use surrounding signals.

1. Monitor failed HTTP requests at the edge

If you still receive traffic on port 80 for URLs that should already be HTTPS-only, log it. That gives you a practical picture of who still relies on HTTP entry points.

Nginx example:

log_format hsts_http '$remote_addr - $host "$request" $status "$http_user_agent"';

server {
    listen 80;
    server_name example.com www.example.com;

    access_log /var/log/nginx/hsts-http.log hsts_http;
    return 301 https://$host$request_uri;
}

This won’t tell you “HSTS would have blocked this,” but it tells you how much HTTP traffic still exists.

2. Use Reporting API for other policy issues

The Reporting API is useful for CSP, COOP, network errors in some contexts, and browser-generated reports depending on support. It is not a substitute for HSTS report-only, but it’s still worth knowing.

Basic reporting endpoint declaration:

Reporting-Endpoints: default="https://reports.example.com/reports"

A tiny Express receiver:

import express from "express";

const app = express();
app.use(express.json({ type: ["application/json", "application/reports+json"] }));

app.post("/reports", (req, res) => {
  console.log(JSON.stringify(req.body, null, 2));
  res.sendStatus(204);
});

app.listen(8080);

You can combine this with CSP report-only while cleaning up insecure references:

Reporting-Endpoints: default="https://reports.example.com/reports"
Content-Security-Policy-Report-Only: default-src 'self'; upgrade-insecure-requests; report-to default

That’s useful because teams often confuse mixed content cleanup with HSTS rollout. They’re related, but not the same mechanism.

3. Track certificate and TLS failures separately

If your real concern is “will HTTPS break for some clients or hosts,” then certificate monitoring and TLS handshake monitoring matter more than imaginary HSTS reports.

Common mistakes

Sending HSTS over HTTP

Browsers ignore HSTS received over plain HTTP.

This does nothing useful:

HTTP/1.1 200 OK
Strict-Transport-Security: max-age=31536000

The header must be delivered over valid HTTPS.

Setting a huge max-age on day one

This is the classic self-own:

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

If one subdomain is broken, you’ve just created a hard failure for users whose browsers cached the policy.

Assuming redirects are equivalent to HSTS

A redirect helps after the first insecure request. HSTS prevents that first insecure hop on subsequent visits.

You want both:

  • HTTP to HTTPS redirect on the server
  • HSTS on the HTTPS response

Forgetting that localhost and fresh browsers behave differently

HSTS only applies after the browser has seen the header for that host. Testing in a clean browser profile often reveals first-visit behavior you won’t notice in your own cached environment.

Copy-paste production examples

Nginx, cautious rollout

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate     /etc/ssl/example/fullchain.pem;
    ssl_certificate_key /etc/ssl/example/privkey.pem;

    add_header Strict-Transport-Security "max-age=300" always;

    root /var/www/html;
    index index.html;
}

Later:

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

Apache

<VirtualHost *:80>
    ServerName example.com
    ServerAlias www.example.com
    Redirect permanent / https://example.com/
</VirtualHost>

<VirtualHost *:443>
    ServerName example.com
    ServerAlias www.example.com

    SSLEngine on
    SSLCertificateFile /etc/ssl/example/fullchain.pem
    SSLCertificateKeyFile /etc/ssl/example/privkey.pem

    Header always set Strict-Transport-Security "max-age=300"
</VirtualHost>

Express with Helmet

import express from "express";
import helmet from "helmet";

const app = express();

app.enable("trust proxy");

app.use((req, res, next) => {
  if (!req.secure) {
    return res.redirect(301, `https://${req.headers.host}${req.url}`);
  }
  next();
});

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

app.get("/", (req, res) => {
  res.send("ok");
});

app.listen(3000);

The practical takeaway

HSTS report-only mode is basically a myth in browser deployment.

If you need a safe way to adopt HSTS, do this instead:

  • verify HTTPS coverage
  • keep HTTP redirects in place
  • start with max-age=300
  • increase gradually
  • hold off on includeSubDomains
  • treat preload as a one-way door

That’s the real-world playbook. It’s boring, but boring is what you want from transport security.