Security Headers for Event Streams Permalink to this section
Part of SSE Protocol Fundamentals & Architecture.
Long-lived HTTP connections inherent to Server-Sent Events introduce attack surfaces that standard request-response security patterns fail to address. A typical REST endpoint is open for milliseconds; an SSE stream stays open for minutes or hours, giving adversaries a sustained window for cross-origin data exfiltration, connection hijacking, cache poisoning, and denial-of-service through connection exhaustion. Without explicit header enforcement at both the application layer and the reverse proxy, event streams expose real-time data to browsers, CDNs, and transparent proxies in ways that break the same-origin guarantees developers assume they get for free.
This guide establishes a production-hardened header strategy covering every layer of the stack: the application server, the reverse proxy, the CDN edge, and the browser security model. Code examples target Node.js/Express, Python/FastAPI, Go, and Nginx because those are the stacks most commonly seen in SSE deployments.
How Security Headers Work for SSE Permalink to this section
The WHATWG HTML specification defines EventSource as a browser-managed long-lived GET request. The browser opens the connection, reads text/event-stream chunks, and fires message events. This differs from a fetch in a critical way: the browser will silently reconnect on network loss, following the retry: field and re-sending Last-Event-ID. Security policy must account for all phases: the initial handshake, the open streaming phase, and every reconnect.
The mandatory header set Permalink to this section
Every SSE endpoint must emit these headers before the first event chunk. Omitting any one of them causes a specific class of failure:
| Header | Required value | Consequence of omission |
|---|---|---|
Content-Type |
text/event-stream; charset=utf-8 |
Browser treats body as text/plain, EventSource fires onerror immediately |
Cache-Control |
no-cache |
Proxies and CDNs cache the response body; clients receive stale or replayed events |
Connection |
keep-alive |
HTTP/1.1 default, but explicit declaration survives proxy normalisation |
X-Accel-Buffering |
no |
Nginx buffers the upstream response until the connection closes |
X-Content-Type-Options |
nosniff |
IE/legacy Edge MIME-sniff the stream and may execute it as script |
Transfer-Encoding |
chunked (auto on HTTP/1.1) |
Required for incremental delivery; explicit on HTTP/1.0 fallback paths |
The X-Accel-Buffering: no header is set by the application, not by Nginx config. When it is present in the upstream response, Nginx honours it even if proxy_buffering on is the default. This is the escape hatch for application-layer control of buffering without touching the Nginx config.
CORS mechanics for EventSource Permalink to this section
EventSource is a credentialed cross-origin request when withCredentials: true is passed. The browser sends an initial preflight-free GET (EventSource uses simple-request headers), but it still enforces the CORS check on the response. Specifically:
- Without
withCredentials:Access-Control-Allow-Origin: *suffices. - With
withCredentials: true: the server must return the exact origin (not*) inAccess-Control-Allow-Origin, plusAccess-Control-Allow-Credentials: true. A wildcard*causes the browser to block the stream silently.
The browser does not send a preflight OPTIONS request before an EventSource GET because EventSource only sets Accept: text/event-stream, which qualifies as a simple request. However, on every reconnect the CORS headers are re-validated, so they must be present on every 200 response, not just the first.
Content Security Policy and connect-src Permalink to this section
CSP’s connect-src directive gates which origins the browser allows for EventSource, fetch, and XMLHttpRequest. A misconfigured CSP that omits the stream origin blocks EventSource before the TCP connection is established:
Content-Security-Policy: default-src 'self'; connect-src 'self' https://stream.example.com; script-src 'self'
If your stream endpoint lives on a different subdomain from the app (common in microservice deployments), you must include that subdomain explicitly in connect-src. A wildcard connect-src * defeats the purpose of CSP entirely.
Server-Side Implementation Permalink to this section
Express.js / Node.js Permalink to this section
Apply headers directly in the route handler, not in a blanket middleware, because Content-Type: text/event-stream would interfere with JSON API routes:
import express from 'express';
import { allowedOrigins } from './config.js';
const app = express();
app.get('/api/stream', (req, res) => {
const origin = req.headers.origin;
// Dynamic CORS: only reflect known origins, never wildcard with credentials
if (origin && allowedOrigins.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin'); // Cache discriminates by origin
}
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-store');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // Disable Nginx buffering
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader(
'Content-Security-Policy',
"default-src 'none'; connect-src 'self'"
);
// Validate the client actually wants event-stream
const accept = req.headers.accept || '';
if (!accept.includes('text/event-stream')) {
res.status(406).json({ error: 'Must accept text/event-stream' });
return;
}
res.flushHeaders(); // Send status + headers immediately, before any event
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n'); // SSE comment keeps connection alive
}, 15_000);
req.on('close', () => {
clearInterval(heartbeat);
res.end();
});
});
Python / FastAPI Permalink to this section
FastAPI via Starlette’s StreamingResponse gives you explicit control over response headers. For more detail on the FastAPI setup see the Python FastAPI SSE Implementation Guide:
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import StreamingResponse
import asyncio
app = FastAPI()
ALLOWED_ORIGINS = {"https://app.example.com", "https://staging.example.com"}
async def event_generator(request: Request):
while True:
if await request.is_disconnected():
break
yield "data: {\"tick\": 1}\n\n"
await asyncio.sleep(1)
@app.get("/stream")
async def stream(request: Request):
origin = request.headers.get("origin", "")
accept = request.headers.get("accept", "")
if "text/event-stream" not in accept:
raise HTTPException(status_code=406, detail="Must accept text/event-stream")
headers = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-store",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; connect-src 'self'",
}
# Only set CORS if origin is in allowlist
if origin in ALLOWED_ORIGINS:
headers["Access-Control-Allow-Origin"] = origin
headers["Access-Control-Allow-Credentials"] = "true"
headers["Vary"] = "Origin"
return StreamingResponse(
event_generator(request),
media_type="text/event-stream",
headers=headers,
)
Go / net/http Permalink to this section
Go’s http.Flusher interface is required for incremental delivery. The full pattern is covered in Go Streaming Patterns for SSE; the security-header layer looks like this:
package main
import (
"fmt"
"net/http"
"time"
)
var allowedOrigins = map[string]bool{
"https://app.example.com": true,
}
func streamHandler(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
// Validate Accept header
if r.Header.Get("Accept") != "text/event-stream" {
http.Error(w, "must accept text/event-stream", http.StatusNotAcceptable)
return
}
origin := r.Header.Get("Origin")
if allowedOrigins[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Vary", "Origin")
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, no-store")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set(
"Content-Security-Policy",
"default-src 'none'; connect-src 'self'",
)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-r.Context().Done():
return
case t := <-ticker.C:
fmt.Fprintf(w, "data: {\"ts\": %d}\n\n", t.Unix())
flusher.Flush() // MUST call Flush after every write
}
}
}
Nginx Reverse Proxy Configuration Permalink to this section
The Nginx layer is the most common source of security misconfigurations in SSE deployments. Two settings are mandatory: proxy_buffering off disables response buffering; proxy_http_version 1.1 prevents Nginx from downgrading to HTTP/1.0 (which has no chunked transfer encoding):
upstream sse_backend {
server 127.0.0.1:3000;
keepalive 32; # reuse backend connections
}
server {
listen 443 ssl http2;
server_name api.example.com;
# HSTS: 2-year max-age, include subdomains
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
location /stream {
proxy_pass http://sse_backend;
proxy_http_version 1.1;
proxy_set_header Connection ''; # clears hop-by-hop header
proxy_buffering off; # mandatory for SSE
proxy_cache off; # never cache stream responses
proxy_read_timeout 3600s; # hold connection for up to 1 h
# Security headers added at proxy layer
add_header X-Content-Type-Options "nosniff" always;
# NOTE: do NOT add Content-Type here; upstream sets it
# NOTE: do NOT add CORS here if upstream handles dynamic origin reflection
# Strip upstream headers that could mislead browsers
proxy_hide_header X-Powered-By;
}
location / {
proxy_pass http://app_backend;
# Standard non-stream config...
}
}
The Connection: '' (empty string) clears the Connection hop-by-hop header that the browser sends. Without it, Nginx forwards Connection: keep-alive to the upstream, which in some frameworks causes the upstream to hold and buffer the response.
Authentication for Long-Lived Streams Permalink to this section
Authentication for SSE is covered in depth in Authenticating SSE Streams with Tokens & Cookies. The key constraint is that EventSource does not support custom request headers. Browsers send only cookies and the Accept: text/event-stream header. This limits auth options to:
- Session cookies — work transparently with
withCredentials: true. The session must have a short idle timeout independent of stream lifetime, or a sliding renewal mechanism on the server. - Token in the query string —
EventSource('/stream?token=abc'). The token is then logged by every proxy, CDN, and WAF access log on the path. Treat this as a last resort and use short-lived tokens (TTL ≤ 60 s). - Fetch-based polyfill — swap
EventSourcefor afetch+ReadableStreamclient that can sendAuthorization: Bearerheaders. The Browser Support & Polyfill Strategies guide covers implementation in detail.
Token validation must happen before flushHeaders() is called. If the token check requires an async database round-trip, do it before setting any SSE headers. A rejected connection should return a standard 401 Unauthorized with Content-Type: application/json, not a partial text/event-stream response.
app.get('/api/stream', async (req, res) => {
// 1. Auth check BEFORE any streaming headers
const token = req.query.token || req.cookies.session;
const user = await validateToken(token); // your auth logic
if (!user) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
// 2. Now safe to set SSE headers and begin stream
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-store');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// 3. Emit first event immediately to confirm stream is live
res.write(`data: {"userId":"${user.id}","connected":true}\n\n`);
// ... rest of handler
});
Edge Cases and Network Interference Permalink to this section
The most insidious failures in SSE security header deployments come from intermediary infrastructure silently dropping or modifying headers.
CDN behaviour Permalink to this section
| CDN / Proxy | Default buffering | Required config |
|---|---|---|
| Cloudflare | Buffers by default on Free/Pro | Enable “Disable Railgun” or use Cache Rule “bypass” for the stream path |
| AWS CloudFront | No buffering for streaming | Set Cache-Control: no-store on origin; CloudFront honours it |
| Fastly | Streams pass-through | Set Surrogate-Control: no-store in addition to Cache-Control |
| GCP Cloud CDN | Buffers responses | Use CDN-Cache-Control: no-store override header |
| Nginx (self-managed) | proxy_buffering on by default |
proxy_buffering off in location block, or X-Accel-Buffering: no from upstream |
Cloudflare additionally strips the Connection: keep-alive header at the edge and terminates HTTP/1.1 keep-alive itself — this is expected and does not break SSE, because Cloudflare maintains its own long-lived connection to the origin.
Transparent proxies and mobile carriers Permalink to this section
Mobile network operators often interpose transparent HTTP proxies that downgrade connections to HTTP/1.0 or inject buffering. Symptoms: the client receives no events for 30–90 s, then gets a burst. Mitigation: send a : heartbeat comment line every 15 s. The comment (a line starting with :) is valid per the Understanding the Event Stream Format spec and forces the proxy to flush buffered bytes to the client.
CORS stripping Permalink to this section
Some WAFs and DLP proxies strip CORS headers from responses deemed “sensitive”. If your Access-Control-Allow-Origin header disappears between the origin and the browser, check WAF response-header filter rules. The symptom is a CORS error in the browser console even though curl shows the header present.
Hop-by-hop headers Permalink to this section
Connection, Keep-Alive, Transfer-Encoding, and Upgrade are hop-by-hop headers: they are consumed by each proxy and must not be forwarded end-to-end. Nginx’s proxy_set_header Connection '' strips the client’s hop-by-hop header before forwarding. Failure to do this causes some upstream servers to enter an unexpected keep-alive state.
Performance and Scale Considerations Permalink to this section
Each open SSE connection holds a file descriptor and a small kernel socket buffer. At 10,000 concurrent streams the memory overhead is roughly 10,000 × (socket buffer ≈ 4–8 KB) = 40–80 MB of kernel memory, plus application heap per connection. Security header processing is negligible at this scale, but token validation can become a bottleneck if it hits a database on every reconnect.
Mitigate reconnect amplification by:
- Setting
retry: 30000\n\n(30 s) at the start of each stream. Clients that lose connection wait 30 s before hammering the auth endpoint again. - Caching validated tokens in a local in-process map (TTL = token expiry) to avoid a database round-trip on every reconnect.
- Issuing a short-lived (60 s) stream-specific token from a lightweight
/auth/stream-tokenendpoint that the client fetches with its real credentials, then passes as a query parameter. This decouples SSE auth latency from your main auth service.
The Rate Limiting & Backpressure Handling guide covers connection-count limits and token-bucket strategies that complement the auth hardening here.
For CORS, the Vary: Origin header is critical when a caching proxy sits between the browser and the stream. Without Vary: Origin, a proxy may cache the response headers for one origin and return those cached headers to a different origin, causing a CORS failure.
Validation and Debugging Permalink to this section
curl one-liner: verify all security headers at once Permalink to this section
curl -v -N \
-H "Accept: text/event-stream" \
-H "Origin: https://app.example.com" \
-H "Cookie: session=your-session-token" \
https://api.example.com/stream 2>&1 | \
grep -E '^< (Content-Type|Cache-Control|Access-Control|X-Content-Type|Content-Security|X-Accel|Strict-Transport|Vary)'
Expected output:
< Content-Type: text/event-stream; charset=utf-8
< Cache-Control: no-cache, no-store
< Access-Control-Allow-Origin: https://app.example.com
< Access-Control-Allow-Credentials: true
< X-Content-Type-Options: nosniff
< Content-Security-Policy: default-src 'none'; connect-src 'self'
< X-Accel-Buffering: no
< Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
< Vary: Origin
DevTools verification steps Permalink to this section
- Open Network tab → filter by
EventStreamor search for/stream. - Click the request → Headers tab → confirm all security headers are present in the Response Headers section.
- Click EventStream sub-tab → confirm events are appearing incrementally (not in a single burst after connection close).
- Open Console → any CORS failure appears as a red error with the blocking header listed.
- Open Application → Cookies → confirm the session cookie has
HttpOnly,Secure, andSameSite=StrictorSameSite=Laxattributes.
Structured logging Permalink to this section
Log the following fields on every SSE connection open and close event to trace security regressions:
{
"event": "sse_connect",
"userId": "u_123",
"origin": "https://app.example.com",
"corsAllowed": true,
"authMethod": "cookie",
"tokenTTL": 3600,
"remoteAddr": "203.0.113.45",
"userAgent": "Mozilla/5.0 ...",
"timestamp": "2026-06-21T10:00:00Z"
}
Alert on: corsAllowed: false spikes (possible CORS misconfiguration), authMethod: none (auth bypass), and connection rates exceeding your expected concurrent-stream ceiling per IP.
Automated header regression test (Node.js / Vitest) Permalink to this section
import { describe, it, expect, afterAll } from 'vitest';
import request from 'supertest';
import app from '../src/app.js';
describe('SSE security headers', () => {
it('emits all required security headers', async () => {
const res = await request(app)
.get('/api/stream')
.set('Accept', 'text/event-stream')
.set('Origin', 'https://app.example.com')
.set('Cookie', 'session=valid-test-session')
.timeout({ response: 500 }); // just check headers, don't read stream
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
expect(res.headers['cache-control']).toMatch(/no-cache/);
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['x-accel-buffering']).toBe('no');
expect(res.headers['access-control-allow-origin']).toBe('https://app.example.com');
expect(res.headers['access-control-allow-credentials']).toBe('true');
expect(res.headers['vary']).toContain('Origin');
});
it('returns 401 for unauthenticated requests', async () => {
const res = await request(app)
.get('/api/stream')
.set('Accept', 'text/event-stream');
expect(res.status).toBe(401);
expect(res.headers['content-type']).toMatch(/application\/json/);
});
it('returns 406 for requests without Accept: text/event-stream', async () => {
const res = await request(app)
.get('/api/stream')
.set('Cookie', 'session=valid-test-session');
expect(res.status).toBe(406);
});
});
⚡ Production Directives
- Set
X-Accel-Buffering: nofrom the application, not only from Nginx config — it survives upstream restarts and config drift. - Never use
Access-Control-Allow-Origin: *on an endpoint that also setsAccess-Control-Allow-Credentials: true; browsers block it andEventSourcesilently fails. - Validate auth tokens before calling
flushHeaders(); once headers are flushed, you cannot change the status code. - Add
Vary: Originwhenever you dynamically reflect theOriginheader — CDN cache poisoning is the consequence of omitting it. - Set
retry: 30000in your first SSE event to prevent reconnect storms amplifying auth-service load after a backend restart.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why does EventSource fail silently when CORS is misconfigured?
The browser enforces CORS on EventSource before firing any events. If the Access-Control-Allow-Origin header is missing, wrong, or uses * with credentials, the browser blocks the response and fires onerror on the EventSource instance with no detail beyond readyState === 2 (CLOSED). The error shows in the browser console as a CORS policy violation. Check DevTools Network tab — the response headers will show what the server actually returned, revealing the mismatch.
Can I set security headers in a middleware that runs before all routes?
For most security headers (HSTS, X-Content-Type-Options, CSP) yes — middleware is fine. However, do not set Content-Type: text/event-stream in global middleware; it will break your JSON API routes. The SSE-specific Content-Type, X-Accel-Buffering, and stream-specific Cache-Control should be set only in the stream route handler, immediately before flushHeaders().
My CDN keeps caching the stream response. Cache-Control: no-cache isn't enough — why?
Several CDNs honour their own cache-control headers independently of the HTTP Cache-Control header. Cloudflare uses Cache-Control: no-store (stronger) or a Page Rule / Cache Rule set to "Bypass". Fastly respects Surrogate-Control: no-store. GCP Cloud CDN uses CDN-Cache-Control: no-store. You may need to set multiple headers or configure a CDN-level rule. Check your CDN's caching documentation for the authoritative override header.
How should I handle token expiry mid-stream?
The server cannot push a 401 status mid-stream; HTTP status codes are part of the initial response headers. Instead, emit a named event: event: auth_expired\ndata: {}\n\n. The client listens for source.addEventListener('auth_expired', ...), closes the EventSource, refreshes the token, and reconnects. Alternatively, send a signed short-lived stream token (TTL = stream max duration) at stream open so the server never needs to check expiry mid-stream.
Does HTTP/2 change any of these header requirements?
Yes. Under HTTP/2, the Connection and Keep-Alive headers are invalid (HTTP/2 manages connection multiplexing at the framing layer). Nginx's proxy_set_header Connection '' correctly strips this before forwarding to the upstream. The SSE data format and event semantics are unchanged. X-Accel-Buffering: no, Cache-Control, CORS, and CSP headers all work identically under HTTP/2. The EventSource API in browsers uses whatever HTTP version the browser negotiates with the server.