LEARNING OBJECTIVES โต
- Understand the Same-Origin Policy (SOP) and Cross-Origin Resource Sharing (CORS) rules governing
EventSource. - Configure
withCredentials: truefor cross-origin cookie-based session authentication. - Overcome the
EventSourcecustom HTTP header limitation using ephemeral ticket authentication. - Configure Content Security Policy (CSP)
connect-srcdirectives to permit real-time streaming endpoints. - Sanitize streaming text payloads to prevent stored and reflected Cross-Site Scripting (XSS) attacks.
๐ The Mental Model & Story (Intuitive Foundation)
Imagine entering an ultra-secure corporate research building.
Scenario A: Same-Origin (Inside Your Own Department)
You work on the 4th floor (https://app.company.com). You walk into the 4th-floor cafeteria (https://app.company.com/stream). The receptionist recognizes your face immediately. No special passport checks are needed.
Frontend: https://app.company.com -----> Backend: https://app.company.com/stream
[ SAME-ORIGIN: AUTOMATIC TRUST ]
Scenario B: Cross-Origin without Credentials (withCredentials: false)
You visit the external partner building across the street (https://api.partner.com/stream). You are allowed to listen to the public lobby TV announcements, but you do not show your employee ID badge. The partner building must post a sign: Access-Control-Allow-Origin: *.
Frontend: https://app.company.com -----> Backend: https://api.partner.com/stream
[ CROSS-ORIGIN (No Badge / No Cookies) ]
[ Server must reply: Access-Control-Allow-Origin: * ]
Scenario C: Cross-Origin with Credentials (withCredentials: true)
You visit the executive partner boardroom across the street and want to access private company data. You flash your official employee badge (HttpOnly Session Cookie).
- The Security Rule: The partner building cannot just allow everyone (
*). They must explicitly verify your exact domain (Access-Control-Allow-Origin: https://app.company.com) and confirm badge access (Access-Control-Allow-Credentials: true).
Frontend: https://app.company.com ===== Badge / Cookies =====> Backend: https://api.partner.com
[ Server verifies exact Origin: https://app.company.com + Allow-Credentials: true ]
Technical Deep Dive & Specifications
1. The CORS Protocol Requirements for EventSource
When connecting to a cross-origin SSE stream:
// Cross-Origin connection with Cookies / Credentials
const sse = new EventSource('https://api.example.com/live-stream', {
withCredentials: true
});
The server must return the following HTTP response headers:
HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Cache-Control: no-cache
Connection: keep-alive
[!CAUTION] If
withCredentials: trueis set, the server MUST NOT sendAccess-Control-Allow-Origin: *. The browser's CORS security policy will reject the connection immediately with a security error.
2. The Custom Header Dilemma & Solutions
The native EventSource API does not support custom request headers (like Authorization: Bearer <jwt>).
// โ INVALID - The browser silently ignores any headers object!
const sse = new EventSource('/stream', {
headers: { 'Authorization': 'Bearer my-jwt-token' }
});
To authenticate SSE streams in production, choose one of these three battle-tested architecture patterns:
+-----------------------------------------------------------------------------------------------+
| SSE AUTHENTICATION STRATEGIES |
+-----------------------------------------------------------------------------------------------+
| Strategy | Mechanism | Security Profile |
+-------------------------------+------------------------------------------+--------------------+
| 1. HttpOnly Session Cookie | Uses `withCredentials: true` | โญ High (Prevents |
| (Recommended for Web) | Cookies sent automatically over HTTPS | XSS token theft) |
+-------------------------------+------------------------------------------+--------------------+
| 2. Single-Use Ticket / Token | Client POSTs for short-lived ticket; | โญ High (Avoids |
| (For API Microservices) | Connects via `?ticket=<single_use_uuid>` | URL token logging) |
+-------------------------------+------------------------------------------+--------------------+
| 3. Fetch + ReadableStream | Uses `fetch()` with custom headers | โญ Moderate-High |
| (Polyfill Approach) | Parses stream chunks manually in JS | (Requires manual |
| | | reconnect logic) |
+-------------------------------+------------------------------------------+--------------------+
The Ephemeral Ticket Pattern (Strategy 2 Walkthrough)
- Client sends authenticated REST call:
POST /api/auth/sse-ticketwithAuthorization: Bearer <token>. - Backend generates a 30-second single-use cryptographically random ticket (stored in Redis).
- Backend returns
{ ticket: "tkt_8f9a2b..." }. - Client opens stream:
new EventSource('/api/stream?ticket=tkt_8f9a2b...'). - Backend validates ticket, burns it from Redis immediately, and keeps the stream open.
CLIENT REST API SSE GATEWAY
| | |
|--- 1. POST /auth/sse-ticket (Bearer)->| |
|<-- 2. HTTP 200 { ticket: "xyz" } -----| |
| |
|--- 3. new EventSource('/stream?ticket=xyz') --------------------------------->|
| | (Validates & Burns xyz)
|<-- 4. HTTP 200 text/event-stream (data: ...) ---------------------------------|
3. Content Security Policy (CSP) Configuration
If your web application enforces a strict Content Security Policy, you must explicitly permit the SSE streaming origin under the connect-src directive:
Content-Security-Policy: default-src 'self'; connect-src 'self' https://api.example.com https://realtime.example.com;
4. Preventing Cross-Site Scripting (XSS) in Real-Time UIs
Because SSE payloads arrive as plain text, injecting raw event.data directly into innerHTML allows malicious users to execute arbitrary JavaScript on other users' screens:
// โ VULNERABLE TO ZERO-CLICK STORED XSS!
sse.onmessage = (e) => {
document.getElementById('chat').innerHTML += `<div>${e.data}</div>`;
};
// โ
SECURE - Use textContent or DOM safe node creation
sse.onmessage = (e) => {
const msgEl = document.createElement('div');
msgEl.textContent = e.data; // Browser auto-escapes HTML/scripts
document.getElementById('chat').appendChild(msgEl);
};
๐ป Interactive Code Playground
Below is an interactive SSE Security & Authentication Playground. It lets you experiment with CORS credentials validation, single-use ticket exchange, and XSS payload rendering safety.
Starter Code
Line-by-Line Code Breakdown
- Lines 105โ118: Demonstrates the ephemeral ticket acquisition pattern: exchanging a long-lived JWT for a short-lived, single-use ticket before instantiating
EventSource. - Lines 120โ128: Simulates the backend gateway validating the ticket and burning it from Redis upon first connection. Even if the URL is logged in proxy logs, the token cannot be reused.
- Lines 135โ138 (
btnRenderVulnerable): Illustrates the classic stored XSS vulnerability when developers assignelement.innerHTML = event.data. - Lines 140โ143 (
btnRenderSecure): Demonstrates the safe sanitization pattern usingelement.textContent, preventing script execution.
Expected Browser Render Output
๐ SSE Security, CORS & Authentication
+------------------------------------+------------------------------------+
| 1. Single-Use Ticket Handshake | 2. XSS Payload Injection Defense |
+------------------------------------+------------------------------------+
| [ POST /auth/sse-ticket ] | [ Render innerHTML ] [ Safe text ] |
| [ Open EventSource(?ticket=...) ] | |
| | Rendered Result: |
| Logs: | <img src=x onerror=...> Hello! |
| [10:55:01] 200 OK: Generated tkt_x | (Safe string, not executed) |
| [10:55:02] Stream OPEN | |
+------------------------------------+------------------------------------+๐๏ธ Hands-On Exercise
๐ฏ The Challenge: Build a Secure Ticket-Authenticated SSE Connector
Instructions:
- Create a function
connectSecureStream(streamUrl, tokenEndpoint, jwtToken): - Step 1: Execute
fetch(tokenEndpoint, { method: 'POST', headers: { 'Authorization': 'Bearer ' + jwtToken } })to obtain a ticket. - Step 2: Parse
{ ticket: string }from the response. - Step 3: Instantiate and return
new EventSource(${streamUrl}?ticket=${ticket}). - Include robust error handling for HTTP 401 Unauthorized responses.
๐ Starter Code Sandbox
โ ๏ธ Common Pitfalls
- Passing
Access-Control-Allow-Origin: *withwithCredentials: true: The browser strictly forbids wildcard origins when credentials are requested. The server must echo the exact client origin:Access-Control-Allow-Origin: https://client.example.com. - Placing Long-Lived JWTs in SSE Query Strings: Writing
new EventSource('/stream?jwt=' + userJwt)exposes sensitive user credentials in access logs, CDN logs, and proxy traces. Always use short-lived ephemeral tickets or HttpOnly cookies. - Rendering SSE Payloads via
innerHTML: Streaming data frequently contains user-generated content (chat messages, logs). Assigning raw text directly toinnerHTMLintroduces critical Cross-Site Scripting (XSS) vulnerabilities. Always sanitize or usetextContent.
๐ก Pro Tips
- Token Refresh via Reconnect Ticket: When an authenticated SSE stream drops and reconnects, an expired URL ticket will cause an HTTP 401. To handle automatic reconnection with fresh tickets, use
@microsoft/fetch-event-sourceor intercept reconnection with a custom token refresh wrapper. - CSP
connect-srcSubdomain Wildcards: For large distributed streaming infrastructure, configureconnect-src https://*.stream.yourdomain.comin your Content Security Policy to allow dynamic edge cluster routing.
๐ Key Takeaways
withCredentials: trueenables sending HttpOnly session cookies across origins.- When
withCredentials: trueis enabled,Access-Control-Allow-Origincannot be*. EventSourcedoes not support custom HTTP headers; use HttpOnly cookies or the Single-Use Ephemeral Ticket pattern.- Content Security Policy requires adding streaming domains to
connect-src. - Always sanitize SSE text payloads before injecting into the DOM to prevent XSS vulnerabilities.
- --