A lot of teams treat Server-Sent Events like “just another endpoint.” That’s how you end up with a perfectly secure app shell over HTTPS and a quietly fragile event stream still hanging onto old HTTP assumptions.
I’ve seen this play out in production: the page loads fine, login works, API calls are on HTTPS, HSTS is enabled on the main site, and yet live updates randomly fail, especially after deploys, browser restarts, or when users hit older bookmarked URLs. The culprit was SSE over an incomplete HTTPS setup.
This is the kind of bug that hides in the gap between “the website is secure” and “every connection path is secure.”
The setup
The app was a dashboard with a live activity feed powered by Server-Sent Events:
- Main app:
https://app.example.com - Event stream:
https://app.example.com/events - Nginx in front
- Node.js app behind it
- HSTS enabled, but only on some responses
- Legacy redirect rules still hanging around from an earlier migration
On paper, this looked fine. In reality, users saw:
- intermittent
EventSourcefailures - reconnect loops
- mixed-content errors in some environments
- event streams that worked after a fresh navigation but failed from bookmarked HTTP URLs
- different behavior across browsers
That inconsistency is what makes these cases annoying. You don’t get one obvious break. You get a stream that “usually works.”
The before state
Here was the original frontend code:
const source = new EventSource("http://app.example.com/events");
source.onmessage = (event) => {
const data = JSON.parse(event.data);
renderActivity(data);
};
source.onerror = (err) => {
console.error("SSE failed", err);
};
Yes, explicit http://. That was the first problem.
The original thinking was: “The server redirects HTTP to HTTPS anyway, so this is harmless.”
That logic is weak for normal requests and especially bad for SSE.
Why this broke in practice
EventSource is long-lived and reconnects automatically. If the browser starts from an insecure URL, it may hit a redirect first, and reconnect behavior can become messy when proxies, caches, old bookmarks, or subdomain differences are involved.
Worse, if the page itself is HTTPS and the stream URL is explicitly HTTP, browsers can block it as mixed content before HSTS even gets a chance to save you.
That’s the first lesson:
HSTS is not a band-aid for hardcoded insecure SSE URLs.
Now the backend.
Nginx before
server {
listen 80;
server_name app.example.com;
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/ssl/app/fullchain.pem;
ssl_certificate_key /etc/ssl/app/privkey.pem;
location / {
proxy_pass http://node_app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /events {
proxy_pass http://node_app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 1h;
}
add_header Strict-Transport-Security "max-age=31536000";
}
This looks close, but there were three real issues:
Strict-Transport-Securitywas not set withalways- some error responses and edge cases skipped the header
- SSE proxy behavior was incomplete for streaming
And the app had its own bug.
Node before
app.get("/events", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
const timer = setInterval(() => {
res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`);
}, 5000);
req.on("close", () => {
clearInterval(timer);
});
});
Functional, but missing a few headers and assumptions needed for stable delivery behind a proxy.
What actually happened
A user opened an old bookmark:
http://app.example.com/dashboard
The browser got redirected to HTTPS and loaded the app. So far, so good.
Then the page created an EventSource using http://app.example.com/events.
Depending on browser state and policy, one of these happened:
- blocked as mixed content
- upgraded because of HSTS cache, if the browser already had a valid HSTS policy for the host
- redirected again, causing noisy reconnects
- failed during proxy edge cases because the stream path wasn’t treated consistently
The killer detail: HSTS only helps after the browser has learned the policy from a secure response. If a user is new, on a fresh browser profile, or using a hostname that never returned HSTS correctly, you still have a first-visit problem.
For SSE, that means reconnects can start from the wrong scheme over and over.
The fix
We changed three things:
- the frontend stopped hardcoding
http:// - HSTS was sent consistently on all HTTPS responses
- the proxy and app were tuned for actual streaming
Frontend after
Best option: same-origin relative URL.
const source = new EventSource("/events");
source.onmessage = (event) => {
const data = JSON.parse(event.data);
renderActivity(data);
};
source.onerror = (err) => {
console.error("SSE connection issue", err);
};
If you truly need a full URL, use https:// explicitly, never http://.
Relative URLs are better here because they inherit the current origin and avoid silly scheme drift.
Nginx after
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/ssl/app/fullchain.pem;
ssl_certificate_key /etc/ssl/app/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://node_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
}
location /events {
proxy_pass http://node_app;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
add_header Cache-Control "no-cache" always;
}
}
A few opinions here:
alwayson HSTS is non-negotiable. If your app only sends HSTS on happy-path 200 responses, you have gaps.includeSubDomainsis great if you actually control and serve all subdomains over HTTPS. Don’t turn it on casually in a messy environment.proxy_buffering offmatters for SSE. Buffered streams are fake streams.- I set
X-Forwarded-Proto httpsexplicitly on the TLS vhost so the app stops guessing.
If you want to sanity-check your headers, run a scan with Headertest. It’s a quick way to catch missing HSTS on unexpected responses.
Node after
app.get("/events", (req, res) => {
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Connection", "keep-alive");
res.flushHeaders?.();
const send = (payload) => {
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
const heartbeat = setInterval(() => {
res.write(`: keepalive ${Date.now()}\n\n`);
}, 15000);
const updates = setInterval(() => {
send({ time: Date.now() });
}, 5000);
req.on("close", () => {
clearInterval(heartbeat);
clearInterval(updates);
res.end();
});
});
The keepalive comment line helps prevent idle timeouts through layers that don’t love quiet long-lived connections.
Why HSTS mattered here
HSTS doesn’t make SSE special. It makes transport rules consistent.
For SSE specifically, that consistency matters more because:
- the connection is long-lived
- reconnects are automatic
- a bad URL keeps failing repeatedly
- users notice live features breaking faster than they notice a one-off API retry
Once HSTS is correctly set, supported browsers that have learned the policy will internally rewrite future http:// requests to https:// for that host before the request goes out.
That helps with:
- old bookmarks
- stale internal links
- accidental insecure reconnect attempts
- subresource requests to the same protected host
But I wouldn’t rely on HSTS to fix lazy frontend code. Clean up the URLs first, then use HSTS as the safety net it’s meant to be.
Results after rollout
After the fix:
- mixed-content SSE failures dropped to zero
- reconnect loops disappeared
- event delivery became stable across browser restarts
- support tickets around “live feed stopped updating” went away
- header scans stopped showing inconsistent HSTS coverage
The app also became easier to reason about. That matters. Security fixes that reduce operational weirdness are the best kind.
Rollout advice if you’re doing this now
A few hard-earned rules:
1. Start with the client code
Search for:
new EventSource("http://- hardcoded absolute URLs
- environment variables that still emit
http://in production
Relative same-origin URLs are usually the cleanest answer.
2. Verify HSTS on real responses
Check:
- 200 responses
- 301/302 redirects
- 4xx/5xx responses
- the SSE endpoint itself
For Nginx, always is the difference between “configured” and “actually present.”
See official docs for header behavior in your stack:
3. Don’t preload casually
Preload can be great, but only when your whole domain strategy is ready for it. If you’re still discovering random HTTP subdomains in old infrastructure, fix that first.
4. Tune your proxy for streaming
SSE is sensitive to buffering, timeouts, and connection handling. HSTS won’t save a stream that your proxy is quietly coalescing or timing out.
5. Test first-visit behavior
Use a fresh browser profile. Existing HSTS cache can hide broken assumptions.
That’s the trap with these bugs: your machine is often the least reliable place to reproduce them because your browser already “knows” the site is HTTPS-only.
The simple version
If your app uses Server-Sent Events, do this:
- use
EventSource("/events")or explicithttps:// - redirect HTTP to HTTPS
- send
Strict-Transport-Securityon every HTTPS response - disable proxy buffering for the stream
- test with a fresh browser profile
That combination turns SSE from “mostly works” into “boringly reliable,” which is exactly what you want from transport security.