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
// 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 ownNon-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.
@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:
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-Originon 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 checkingOriginagainst an allowlist, as above (WebSocket Security). - SameSite is about sites, not origins.
app.example.comtoapi.example.comis same-site, soLaxcookies are sent. A cross-site socket needsSameSite=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.
- The page asks an ordinary HTTP endpoint for a ticket, authenticated by whatever the API already uses: a cookie or a bearer token.
- The server stores a random ticket in Redis for about 30 seconds and returns it.
- The page opens the socket with the ticket in the URL; the server consumes it atomically, so it works exactly once.
@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 replicaconst 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
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 throwSyntaxError. - 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.
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 tokenKubernetes 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
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 NoneNothing 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.
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:
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.
@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 eventKeep 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:
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.
Related
Interview angle 5
- “How do you authenticate a WebSocket from a browser?” — the browser
cannot send
Authorization, so: same-site session cookies with anOriginallowlist, 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.
- Follow-up: “Your endpoint rejects bad tokens with close(4001), and
clients still reconnect in a tight loop. Why?” — closing before
- “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
Originagainst an allowlist, or use tickets instead of cookies. - “How do you authenticate an SSE stream?” —
EventSourcesends no headers, so same-site cookies (cross-origin they needwithCredentialsand exact CORS headers), or a ticket in the URL. Or read the stream withfetch()and sendAuthorization, giving up automatic reconnect.- Follow-up: “Your ticket-authenticated SSE dashboard stops updating after
a network blip. Why?” —
EventSourcereconnected with the spent ticket, got a 401, and any non-200 closes it for good. Get a fresh ticket inonerrorand reconnect with the last event id, or use cookies.
- Follow-up: “Your ticket-authenticated SSE dashboard stops updating after
a network blip. Why?” —
- “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.