obsidian/wiki/concepts/sse-jwt-query-param.md
2026-05-06 21:05:03 +01:00

4.5 KiB
Raw Blame History

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
daily/2026-05-06.md
2026-05-06 2026-05-06

SSE / EventSource Does Not Support Custom Headers — Pass JWT as Query Param

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())

Alternative: fetch() + ReadableStream (Supports Headers)

// ✅ 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 (60300 s) via a separate endpoint. This limits the window of exposure if a token is leaked via logs.

Sources