| title |
aliases |
tags |
sources |
created |
updated |
| SSE / EventSource Does Not Support Custom Headers — Pass JWT as Query Param |
| sse-no-headers |
| eventsource-jwt |
| sse-auth-token |
|
| sse |
| eventsource |
| jwt |
| auth |
| browser |
| security |
| gotcha |
|
|
2026-05-06 |
2026-05-06 |
The browser-native EventSource API cannot set custom HTTP headers. There is no headers option in the constructor. For JWT-authenticated SSE streams, the token must be appended as a query parameter (?token=...). This is a known browser limitation with no workaround short of switching to fetch() with a ReadableStream.
Key Takeaways
new EventSource(url) sends only cookies and browser-default headers — no way to add Authorization: Bearer
- JWT must travel as a URL query parameter:
?token=<jwt> for browser-native SSE
- Query-param tokens are visible in server access logs — use short-lived tokens (< 60 s) and rotate them
- Alternative: use
fetch() + ReadableStream (SSE-over-fetch) which does support custom headers, but requires manual reconnect logic
- Backend must accept both
Authorization header (for regular API calls) and ?token= (for SSE)
Details
The Problem
// ❌ This is the intuitive approach — but EventSource has no headers option
const es = new EventSource("/stream", {
headers: { Authorization: `Bearer ${token}` } // IGNORED — not a valid option
});
Fix: Token as Query Parameter
// ✅ Pass token as query param
const token = localStorage.getItem("access_token");
const es = new EventSource(`/stream?token=${encodeURIComponent(token)}`);
es.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data);
};
es.onerror = (err) => {
es.close();
// Reconnect logic here
};
FastAPI Backend: Accept Both Header and Query Param
from fastapi import Query, Header, HTTPException
from typing import Optional
async def get_current_user_sse(
token: Optional[str] = Query(None),
authorization: Optional[str] = Header(None),
):
"""Auth handler that accepts JWT from header OR query param (for SSE)."""
raw_token = None
if authorization and authorization.startswith("Bearer "):
raw_token = authorization.removeprefix("Bearer ")
elif token:
raw_token = token
else:
raise HTTPException(status_code=401, detail="Missing token")
return verify_jwt(raw_token) # your existing JWT verifier
@app.get("/stream")
async def stream_events(
request: Request,
user = Depends(get_current_user_sse),
):
async def event_generator():
while True:
if await request.is_disconnected():
break
yield {"data": json.dumps({"status": "ok"})}
await asyncio.sleep(2)
return EventSourceResponse(event_generator())
// ✅ Full header support, but you must implement reconnect manually
const response = await fetch("/stream", {
headers: { Authorization: `Bearer ${token}` },
signal: abortController.signal,
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
// Parse SSE text format manually: "data: {...}\n\n"
processSSEChunk(text);
}
Security Considerations
| Approach |
Token exposure |
Reconnect |
Browser support |
?token= query param |
Server logs, referrer headers |
Automatic |
Universal |
fetch() + ReadableStream |
Headers only (safe) |
Manual |
Modern browsers |
| Cookie (httpOnly) |
Minimal |
Automatic |
Universal, requires CORS cookie config |
[!tip] Prefer short-lived tokens for SSE
If using query param auth, generate a dedicated short-lived SSE token (60–300 s) via a separate endpoint. This limits the window of exposure if a token is leaked via logs.
Related
Sources