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 EventSource failures
  • 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:

  1. Strict-Transport-Security was not set with always
  2. some error responses and edge cases skipped the header
  3. 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:

  1. the frontend stopped hardcoding http://
  2. HSTS was sent consistently on all HTTPS responses
  3. 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:

  • always on HSTS is non-negotiable. If your app only sends HSTS on happy-path 200 responses, you have gaps.
  • includeSubDomains is great if you actually control and serve all subdomains over HTTPS. Don’t turn it on casually in a messy environment.
  • proxy_buffering off matters for SSE. Buffered streams are fake streams.
  • I set X-Forwarded-Proto https explicitly 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 explicit https://
  • redirect HTTP to HTTPS
  • send Strict-Transport-Security on 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.