Back to Blog
SecurityJune 30, 20264 min read

Server-Sent Events Security: Preventing Connection Hijacking

Server-Sent Events security is often overlooked. Learn how to prevent connection hijacking and data leakage in your Node.js and PHP real-time applications.

Server-Sent EventsSecurityNode.jsPHPWeb SecurityReal-timeWebBackend

When I first started building real-time dashboards, I treated Server-Sent Events (SSE) as a "set it and forget it" feature. I figured since it was just a one-way stream from server to client, it was inherently safer than a bidirectional WebSocket. I was wrong. During a routine audit, I realized that an improperly configured SSE endpoint could leak sensitive user data to any unauthenticated actor who guessed the URL.

If you’re building real-time features, SSE is a fantastic, lightweight choice. But treating it as a public pipe is a recipe for disaster. Here is how to lock down your streams.

Understanding Server-Sent Events Security Risks

The primary SSE vulnerability stems from the fact that standard browser EventSource objects don’t support custom headers. Because you can’t easily send an Authorization header during the initial connection request, developers often resort to passing tokens via query parameters.

This is a mistake. Query parameters are logged in server access logs, browser history, and proxy logs. If your token is in the URL, it’s effectively public.

We’ve also dealt with connection hijacking, where an attacker keeps a connection open indefinitely to exhaust server resources. In a Node.js environment, every open SSE connection consumes a file descriptor and memory. If an attacker opens 500 connections from a single IP, your event loop starts sweating.

Securing Your SSE Implementation

You need to treat an SSE endpoint with the same rigor as a standard API route. If you're looking for Server-Sent Events: Optimizing Real-Time Performance for Dashboards, make sure that performance doesn't come at the cost of security.

1. Don't Pass Tokens in URLs

Instead of GET /events?token=xyz, use a short-lived "pre-flight" authentication pattern.

  • The client sends a POST request to /api/events/handshake with standard auth headers.
  • The server returns a single-use, short-lived (e.g., 5 seconds) UUID.
  • The client connects to /events?sid=uuid.
  • The server validates the UUID, associates it with the user session, and immediately invalidates it.

2. Implement Connection Limits

In Node.js, using express or fastify, you should track active connections per user ID. If a single user ID exceeds a threshold—say, 5 concurrent connections—reject the new request with a 429 Too Many Requests status.

3. Header Hardening

Always set the correct headers to prevent caching. If a proxy caches your SSE stream, the next user might receive sensitive data intended for someone else. You should follow the principles outlined in Cache-Control Header Security: Preventing Sensitive Data Exposure to ensure your stream headers are strictly no-store.

Comparing SSE Security Strategies

StrategyComplexitySecurity LevelBest For
URL TokensLowVery LowPublic data only
Pre-flight HandshakeMediumHighSensitive dashboards
Cookie-based AuthMediumMediumSame-origin apps
IP Rate LimitingLowModeratePreventing DoS

Code Example: Node.js (Express)

Here’s a simplified pattern for managing secure connections. I usually enforce a strict timeout to ensure stale connections don't linger.

JAVASCRIPT
const activeConnections = new Map();

app.get(CE9178">'/events', (req, res) => {
  const userId = req.session.userId;
  
  // 1. Check for max connections
  if ((activeConnections.get(userId) || 0) >= 3) {
    return res.status(429).send(CE9178">'Too many connections');
  }

  // 2. Set headers
  res.writeHead(200, {
    CE9178">'Content-Type': CE9178">'text/event-stream',
    CE9178">'Cache-Control': CE9178">'no-store', // Crucial
    CE9178">'Connection': CE9178">'keep-alive'
  });

  // 3. Track connection
  activeConnections.set(userId, (activeConnections.get(userId) || 0) + 1);

  req.on(CE9178">'close', () => {
    activeConnections.set(userId, activeConnections.get(userId) - 1);
  });
});

A Note on PHP

If you’re running this on PHP-FPM, you have a different set of headaches. Because PHP-FPM processes are usually tied to a request-response cycle, long-running SSE connections can tie up your worker pool. If you have 50 workers and 50 users open an SSE connection, your site effectively goes down for everyone else.

If you must use PHP, offload the SSE handling to a dedicated process like Swoole or ReactPHP, or use a separate broadcasting service. Never hold a standard PHP-FPM worker open for an event stream.

Final Thoughts

I’m still refining how we handle reconnection logic. Sometimes, the client-side EventSource retries too aggressively during a server hiccup, leading to a "thundering herd" problem that looks a lot like a DDoS attack. We’ve started implementing exponential backoff on the client side to mitigate this.

Security isn't a feature you toggle on; it's a constraint you build into your architecture. Start by moving tokens out of your URLs, enforce strict connection limits, and always, always keep your cache headers locked down.

FAQ

Can I use JWTs for SSE? Yes, but only if you use the pre-flight handshake method. Don't put the JWT in the query string.

Does SSE work over HTTPS? It must. If you aren't using TLS, your data is visible to anyone on the network path.

How do I handle session expiration during a stream? The server should periodically check the session state. If the session expires, send a custom "close" event to the client and forcefully terminate the connection from the server.

Similar Posts