Backend / Protocols / WebSockets / 04_authentication.md

WebSocket and SSE Authentication

Updated 5 interview angles 14 min read source
On this page15
  1. The browser constraint
  2. Refuse before or after the handshake
  3. Pattern 1: cookies, with an Origin check
  4. Pattern 2: a one-time ticket
  5. Pattern 3: a token in the query string
  6. Pattern 4: the subprotocol carrier
  7. Pattern 5: authenticate in the first message
  8. Staying authenticated for hours
  9. SSE: the same constraint, different failure rules
  10. Enforce it at the edge
  11. Django Channels
  12. Where it breaks in production
  13. The trade not taken: cookies alone
  14. Related
  15. Interview angle

WebSocket and SSE Authentication

Article.

A browser cannot put an Authorization header on a WebSocket handshake, or on an EventSource request. The WebSocket handshake is a plain GET, and from page JavaScript it carries exactly three things you control: cookies (sent automatically), the URL, and the Sec-WebSocket-Protocol values.

How do you authenticate a browser WebSocket or SSE stream for the whole life of the connection? Two questions: who are you at the start, and are you still allowed an hour later.

Most of this note is about WebSockets; SSE shares the constraint but not the failure rules, and has its own section.

The browser constraint

javascript
// No way to pass headers: the constructor takes a URL and subprotocols only
new WebSocket("wss://api.example.com/ws", { headers: { Authorization: "..." } });

// What a page can do:
new WebSocket("wss://api.example.com/ws?ticket=" + ticket);    // the URL
new WebSocket("wss://api.example.com/ws", ["app.v1", "bearer." + jwt]);  // protocols
// ...and cookies for api.example.com ride along on their own

Non-browser clients (Python services, CLIs, mobile apps with their own networking) set headers freely, and should use a real Authorization header. Everything below is about the browser.

Refuse before or after the handshake

Where the server says no decides what the client learns. In FastAPI and Starlette, close() before accept() cannot send a Close frame, because there is no WebSocket yet, so the server answers the upgrade with HTTP 403. The browser reports close code 1006 and hides the status from the page. A client coded “stop reconnecting on 4001” never sees a 4001, and retries forever: a reconnect storm out of an auth failure.

python
@app.websocket("/ws")
async def ws(websocket: WebSocket):
    if websocket.headers.get("origin") not in ALLOWED_ORIGINS:
        await websocket.close(code=1008)    # before accept(): HTTP 403, page sees 1006
        return
    user = await authenticate(websocket)    # ticket, cookie or subprotocol, below
    await websocket.accept()
    if user is None:
        await websocket.close(code=4401, reason="login required")  # client sees 4401
        return
    await serve(websocket, user)

Reject at the handshake for traffic you never want: a foreign Origin, bots, rate limits. Accept and then close with an application code when a legitimate client must learn what to do next. For non-browser clients, websocket.send_denial_response(JSONResponse(..., status_code=401)) answers the upgrade with a readable 401 and a body. The protocol side of this is in The WebSocket protocol.

Situation Server sends Client should
foreign Origin, abuse HTTP 403 (page sees 1006) back off with jitter
no valid credential 4401 after accept get a fresh ticket, reconnect
authenticated, not allowed 4403 after accept stop; do not reconnect
token expired mid-session 4401 refresh, reconnect

Codes 4000-4999 are yours to define; 4401 and 4403 mirror the HTTP statuses so nobody has to look them up.

Pattern 1: cookies, with an Origin check

The handshake sends the cookies for the WebSocket’s host, so an app that already has session cookies is authenticated for free:

python
user = await user_from_session(websocket.cookies.get("session"))

Three things the usual summaries get wrong:

  • CORS does not apply. The browser makes no preflight and enforces no Access-Control-Allow-Origin on a WebSocket. Any page on any site can open a socket to your server, and the browser attaches your cookies. That is cross-site WebSocket hijacking (CSWSH), and the only defence on the server is checking Origin against an allowlist, as above (WebSocket Security).
  • SameSite is about sites, not origins. app.example.com to api.example.com is same-site, so Lax cookies are sent. A cross-site socket needs SameSite=None; Secure, and Safari and Firefox block those third-party cookies anyway, so cross-site cookie auth is unreliable.
  • HttpOnly does not make it XSS-safe. Script injected into your page simply opens new WebSocket(...), and the cookie rides along. HttpOnly stops the cookie being stolen, not used.

Same-site browser apps with session cookies: this is the simplest choice, as long as the Origin check is in place.

Pattern 2: a one-time ticket

The robust default for browser apps, and the only clean one across sites.

  1. The page asks an ordinary HTTP endpoint for a ticket, authenticated by whatever the API already uses: a cookie or a bearer token.
  2. The server stores a random ticket in Redis for about 30 seconds and returns it.
  3. The page opens the socket with the ticket in the URL; the server consumes it atomically, so it works exactly once.
python
@app.post("/ws/ticket")
async def ws_ticket(user: Annotated[User, Depends(current_user)]) -> dict:
    ticket = secrets.token_urlsafe(32)
    await redis.set(f"wst:{ticket}", user.id, ex=30)
    return {"ticket": ticket}

async def user_for_ticket(ticket: str | None) -> str | None:
    if not ticket:
        return None
    return await redis.getdel(f"wst:{ticket}")    # atomic: one use, any replica
javascript
const r = await fetch("/ws/ticket", { method: "POST", credentials: "include" });
if (r.status === 401) return redirectToLogin();   // readable, unlike a 1006
const { ticket } = await r.json();
const ws = new WebSocket(`wss://api.example.com/ws?ticket=${ticket}`);

GETDEL (Redis 6.2+) reads and deletes in one step; a GET followed by a DEL lets two replicas accept the same ticket. The real authentication happens on the HTTP request, with all of its machinery, and an auth failure surfaces there as a 401 the page can read. What lands in the URL is useless seconds later.

Pattern 3: a token in the query string

javascript
new WebSocket(`wss://api.example.com/ws?token=${accessToken}`);

It works in every client, and it leaks. Not through browser history or Referer: WebSocket URLs go into neither. The leaks are server-side: nginx and load-balancer access logs record the request line with its query string, and so do proxies, APM and tracing URL attributes, and error trackers.

If you must, mint a dedicated token: aud set to the WebSocket service so your other APIs reject it, a lifetime of a minute, and scrubbing rules for token= in every log pipeline. At that point it is a ticket that cannot be consumed once, so prefer the ticket.

Pattern 4: the subprotocol carrier

Page JavaScript can set Sec-WebSocket-Protocol, so a token can travel as a fake subprotocol. Two rules make or break it:

  • Each value must be an HTTP token. Base64url JWTs pass (., -, _ are allowed), but standard base64 with /, + or = padding makes the constructor throw SyntaxError.
  • The server must echo exactly one offered value, or the browser fails the handshake. With a lone ["bearer.<token>"], the only possible echo is the token itself. Offer a real protocol beside it and echo that.
python
offered = websocket.scope.get("subprotocols", [])     # ["app.v1", "bearer.eyJ..."]
token = next((p.removeprefix("bearer.") for p in offered
              if p.startswith("bearer.")), None)
user = await user_from_token(token)
await websocket.accept(subprotocol="app.v1")          # never echo the token

Kubernetes does exactly this for kubectl exec from browsers, with a base64url.bearer.authorization.k8s.io.<token> value beside its channel protocol. It keeps the token out of URLs, but it is fragile: some proxies and WAFs log or mangle the header, and the value is still a long-lived token on every handshake.

Pattern 5: authenticate in the first message

python
await websocket.accept()
try:
    async with asyncio.timeout(5):                   # unauthenticated sockets cost
        msg = await websocket.receive_json()
except TimeoutError:
    await websocket.close(code=4401, reason="auth timeout")
    return
user = await user_from_token(msg.get("token")) if msg.get("type") == "auth" else None

Nothing sensitive touches a URL or header, and the message can carry richer proof (a fresh token, an MFA assertion). The cost is that every connection is accepted before anyone is known: a bot can hold thousands of sockets for the timeout each. Keep the timeout short, cap connections per IP, and send nothing until the auth message has been verified.

Choosing

Pattern What can leak Use when
Cookies + Origin check nothing new same-site browser apps with sessions
One-time ticket a dead ticket in a log the default; cross-site; any SPA
Token in the query a live token in logs never with a normal access token
Subprotocol a live token in headers URLs are logged and tickets are impossible
First message nothing non-trivial auth payloads; with tight limits

Staying authenticated for hours

Authentication at the handshake happens once. A token that expires 15 minutes into a three-hour session, a logout in another tab, a role removed by an admin: none of them touches an open socket unless you make them.

python
async def serve(ws: WebSocket, claims: dict) -> None:
    while True:
        try:
            async with asyncio.timeout(max(claims["exp"] - time.time(), 0)):
                msg = await ws.receive_json()
        except TimeoutError:
            await ws.close(code=4401, reason="token expired")
            return
        if msg.get("type") == "reauth":              # a fresh token, in-band
            try:
                claims = verify(msg["token"], sub=claims["sub"])  # same user only
            except jwt.InvalidTokenError:
                await ws.close(code=4401, reason="re-auth failed")
                return
            continue
        if not allowed(claims, msg):                 # authorize every message
            await ws.send_json({"type": "error", "error": "forbidden"})
            continue
        await handle(ws, msg, claims)

What the loop enforces

  • A deadline per connection. The socket closes when the token expires, unless the client has re-authenticated in-band; a client that refreshes its access token sends {"type": "reauth", "token": ...} a minute early.
  • Re-auth cannot switch users. The new token must carry the same sub.
  • Authorization per message and per channel. Checking at connect only means a user removed from a room keeps receiving it. Check on every subscribe and publish, against current permissions.

Logout and revocation arrive from outside the socket. Publish a per-user event on the broker the nodes already share (Scaling WebSocket Applications), with one pattern subscription per node and a local map from user to sockets — not a Redis connection per socket — and close that user’s sockets with 4401 on every node (JWT Revocation and Logout).

SSE: the same constraint, different failure rules

EventSource takes a URL and nothing else: GET only, no headers, no body. So the options are the same as for a WebSocket — cookies or a ticket in the URL — but the browser treats the stream as an ordinary fetch, and that changes the details (Server-Sent Events (SSE)):

WebSocket EventSource
Custom headers none none
Cookies cross-origin sent if SameSite allows; no CORS only with withCredentials and CORS
Cross-site read yes, unless you check Origin no: CORS withholds the response
Auth failure 1006, or your close code any non-200 closes it for good
Reconnect your code automatic, to the same URL

Cookies and CORS

Same-origin, EventSource sends cookies on its own. Cross-origin it needs new EventSource(url, { withCredentials: true }) and a response with Access-Control-Allow-Origin set to the exact origin plus Access-Control-Allow-Credentials: true.

That makes SSE safer than a WebSocket by default: a page on another site cannot read your stream unless you allow its origin, so there is no SSE equivalent of CSWSH — as long as the server never reflects arbitrary origins with credentials.

A ticket meets automatic reconnects

EventSource reconnects by itself after a network blip, to the same URL. With a one-time ticket in that URL, the reconnect presents a spent ticket, gets a 401, and a non-200 ends the EventSource for good: readyState becomes CLOSED and it never retries. The client has to fetch a fresh ticket and build a new EventSource, carrying the resume cursor itself, because a new EventSource has no Last-Event-ID to send:

javascript
function connect(ticket, lastId = "") {
  const es = new EventSource(`/events?ticket=${ticket}&after=${lastId}`);
  es.onmessage = (e) => { lastId = e.lastEventId; render(e.data); };
  es.onerror = async () => {
    if (es.readyState !== EventSource.CLOSED) return;   // still retrying itself
    const r = await fetch("/sse/ticket", { method: "POST", credentials: "include" });
    if (r.ok) connect((await r.json()).ticket, lastId);
    else redirectToLogin();
  };
}

That is why same-site cookies fit EventSource best: its reconnects just work, Last-Event-ID included. Tickets fit streams the page manages itself.

Ending a stream when the session expires

The stream authenticates once, at its request, so cap its lifetime at the session’s expiry and end it cleanly. With cookies, EventSource reconnects on its own and the new request is checked again; an expired session then gets a 401 and the app sends the user to log in.

python
@app.get("/events", response_class=EventSourceResponse)
async def events(session: Annotated[Session, Depends(current_session)]):
    feed = aiter(user_feed(session.user_id))
    while (left := session.expires_at.timestamp() - time.time()) > 0:
        try:
            async with asyncio.timeout(left):    # wraps the await, never the yield
                event = await anext(feed)
        except (TimeoutError, StopAsyncIteration):
            return                               # clean end: the client reconnects
        yield event

Keep the timeout around the await and the yield outside it. A yield inside asyncio.timeout suspends the generator while the framework sends the event, so the cancellation can land in the framework’s code instead of yours.

Or use fetch, and send the header

A stream read with fetch() and a parser can use any method and any header, including Authorization: Bearer, so none of the workarounds apply. The price is EventSource’s reconnect and Last-Event-ID, which the client rebuilds itself. That is the shape chat UIs use for a POST prompt, and the shape remote MCP servers require: MCP’s authorization spec sends Authorization on every request and forbids access tokens in query strings.

Enforce it at the edge

Unauthenticated upgrades can be stopped before they reach a worker. nginx’s auth_request runs a subrequest for the upgrade GET like any other request, so an auth service can approve the ticket or cookie first. AWS API Gateway WebSocket APIs run a Lambda authorizer on the $connect route only; every later message is trusted, so per-message authorization stays in the app.

Django Channels

AuthMiddlewareStack fills scope["user"] from the Django session, and AllowedHostsOriginValidator is the Origin check — leave it out and the app is open to CSWSH. For tokens, write the middleware with BaseMiddleware:

python
class TicketAuthMiddleware(BaseMiddleware):
    async def __call__(self, scope, receive, send):
        ticket = parse_qs(scope["query_string"].decode()).get("ticket", [None])[0]
        user_id = await user_for_ticket(ticket)
        scope["user"] = await get_user(user_id) if user_id else AnonymousUser()
        return await super().__call__(scope, receive, send)

get_user = database_sync_to_async(lambda pk: User.objects.get(pk=pk))

application = ProtocolTypeRouter({"websocket": AllowedHostsOriginValidator(
    TicketAuthMiddleware(URLRouter(websocket_urlpatterns)))})

In the consumer, self.close() inside connect() before self.accept() rejects the handshake exactly as above, so accept first and close with 4401. A middleware that wraps verification in except Exception turns an identity provider outage into “anonymous user”; catch jwt.InvalidTokenError only.

Where it breaks in production

The reconnect storm

Every refusal written as close(4001) before accept(). Browsers see 1006, the client’s “don’t retry on 4001” rule never fires, and every expired session reconnects in a loop. Accept, then close with the code; or fail earlier, on the ticket request.

Cookies that never arrive

The API moves to another site (api.example.net for an app on example.com), and the WebSocket starts failing for Safari and Firefox users only: their browsers block the now-third-party cookie. A ticket fetched from the same site works everywhere.

A socket that outlives its user

The user is fired, the account disabled, the session revoked, and their dashboard socket keeps streaming for the rest of the day, because nothing but the handshake ever checked. The deadline, the per-message checks and the per-user close event are what end it.

An EventSource that dies on its first blip

An SSE stream authenticated with a one-time ticket works until the first network hiccup. EventSource reconnects with the spent ticket, gets a 401, and closes for good, and the dashboard silently stops updating. Handle onerror with readyState === CLOSED, or use cookies for SSE.

Tokens in the access logs

A query-string token shows up in nginx logs, then in the log pipeline, then in a third-party log vendor. Tickets make the logged value worthless; for anything else, scrub token= at the first hop.

The trade not taken: cookies alone

Same-site cookies plus an Origin allowlist need no extra endpoint, no Redis and no client code, and they are the right answer for a same-site app with sessions. The ticket costs an HTTP round trip and a Redis key per connection, and buys independence from cookie policy: it works across sites, from any client, with any auth scheme the HTTP API already has, and its failure is a readable 401 instead of a silent 1006.

Interview angle 5

  • “How do you authenticate a WebSocket from a browser?” — the browser cannot send Authorization, so: same-site session cookies with an Origin allowlist, or a one-time ticket from an authenticated HTTP endpoint, passed in the URL and consumed atomically. Tokens in the query string leak into access logs; the subprotocol trick works but is fragile.
    • Follow-up: “Your endpoint rejects bad tokens with close(4001), and clients still reconnect in a tight loop. Why?” — closing before accept() rejects the handshake with HTTP 403, the browser reports 1006, and the client’s “stop on 4001” rule never fires. Accept, then close with the application code.
  • “A JWT expires 15 minutes into a 3-hour WebSocket session. What happens today, and what should?” — today nothing: auth ran once, at the handshake. Add a per-connection deadline at the token’s exp, in-band re-auth that extends it for the same user, and a per-user event that closes the sockets on every node at logout.
    • Follow-up: “And a user removed from a chat room?” — authorize every subscribe and publish against current permissions, not only at connect.
  • “Does SameSite=Lax protect your cookie-authenticated socket from CSWSH?” — only against cross-site pages. XSS, or a takeover of a sibling subdomain, is same-site and the cookie is sent. Validate Origin against an allowlist, or use tickets instead of cookies.
  • “How do you authenticate an SSE stream?” — EventSource sends no headers, so same-site cookies (cross-origin they need withCredentials and exact CORS headers), or a ticket in the URL. Or read the stream with fetch() and send Authorization, giving up automatic reconnect.
    • Follow-up: “Your ticket-authenticated SSE dashboard stops updating after a network blip. Why?” — EventSource reconnected with the spent ticket, got a 401, and any non-200 closes it for good. Get a fresh ticket in onerror and reconnect with the last event id, or use cookies.
  • “Why not just put the access token in the URL?” — it lands in nginx and load-balancer access logs, proxies, traces and error trackers. Not browser history or Referer, which is a common wrong answer. A one-time ticket makes the logged value worthless.