While working on a local Express.js project, I ran into a strange problem: occasionally, clicking a link like /about
would open https://localhost:3002/about
instead of http://localhost:3002/about
. Since nothing was running over TLS locally, Safari would display:
Inspecting the HTML confirmed there were no https://
URLs, just relative links. Yet sometimes they worked, sometimes they didn’t. Even more oddly, my server logs showed no incoming request when it failed. If I manually changed https://
to http://
in the address bar, the page loaded fine.
The Cause
This wasn’t a server bug, it was the browser.
Safari (and other browsers) can force HTTPS in certain situations, even for localhost
:
-
HSTS caching
If a site ever sends aStrict-Transport-Security
(HSTS) header, browsers remember it and will always try HTTPS for that host, even if you later serve it over HTTP. Once set, HSTS applies to all future requests until it expires or is manually cleared. -
HTTPS-Only / Auto-upgrade
Some browsers offer an “upgrade to HTTPS” mode. If it’s on, any HTTP link may be rewritten to HTTPS automatically. -
Other scheme-forcers
A<base href="https://...">
, a service worker redirect, or cached state from another app using the same hostname/port.
Why It Happened in My Case
Looking at my app.js
, I was using helmet
for security headers:
const helmetOptions = {};
if (process.env.NODE_ENV === "production") {
helmetOptions.contentSecurityPolicy = {
/* ... */
};
} else {
helmetOptions.contentSecurityPolicy = false;
}
app.use(helmet(helmetOptions));
By default, helmet
sends an HSTS header unless you explicitly disable it. So even in development, Safari may have cached HSTS for localhost
, causing it to auto-upgrade requests.
The Fix
Here’s what I did:
1. Disable HSTS in development
if (process.env.NODE_ENV === "production") {
helmetOptions.contentSecurityPolicy = {
/* strict CSP */
};
} else {
helmetOptions.contentSecurityPolicy = false;
helmetOptions.hsts = false; // Prevents HTTPS auto-upgrade in dev
}
app.use(helmet(helmetOptions));
2. Clear HTTPS state for localhost
in Safari
Safari → Settings → Privacy → Manage Website Data…
Search for localhost
and 127.0.0.1
→ Remove.
3. Check for other causes
- Make sure there’s no
<base href="https://localhost:3002/">
in your HTML templates. - Disable “Require HTTPS” or “Automatically upgrade to HTTPS” in Safari Advanced settings while debugging.
- In the DevTools console, unregister any service workers:
navigator.serviceWorker .getRegistrations() .then(regs => regs.forEach(r => r.unregister()));
- Disable cache in DevTools during testing.
The Result
After:
- Disabling HSTS in dev via
helmetOptions.hsts = false
- Clearing Safari’s website data for
localhost
… the random HTTPS redirects disappeared completely.
Tip: If you ever run a local dev server behind a reverse proxy (like Caddy) that uses HTTPS for
localhost
, browsers may cache that as HSTS too. In that case, either use a different dev hostname (e.g.,http://dev.local:3002
) or clear the cached state before switching back to plain HTTP.