WebSocket Authentication
Browsers can’t set arbitrary headers on the WebSocket handshake. This breaks the typical Authorization: Bearer <token> pattern. Five common workarounds, each with trade-offs.
The browser constraint
// You CAN'T do this from browser JS:
new WebSocket("wss://...", {
headers: { "Authorization": "Bearer ..." } // no such API
});
// You CAN do this:
new WebSocket("wss://example.com/ws?token=..."); // query param
new WebSocket("wss://example.com/ws", ["bearer.eyJhbG..."]); // subprotocol hack
// And the handshake will include any cookies set for the origin.
The constraint is browser-side. Non-browser clients (Python, server-to-server) can set headers freely.
Pattern 1: Cookie-based session
The default if your site already uses sessions.
@app.websocket("/ws")
async def ws(websocket: WebSocket):
session_id = websocket.cookies.get("session")
user = await get_user_from_session(session_id)
if not user:
await websocket.close(code=4001, reason="Auth required")
return
await websocket.accept()
# ...
Pros:
- Browser sends cookies automatically on handshake.
- Same auth as your HTTP endpoints.
- HttpOnly cookies are XSS-safe.
Cons:
- Cross-origin requires CORS headers and
SameSite=None; Securecookies. - CSWSH risk if you don’t validate
Origin(see 06_security.md). - Doesn’t work for non-browser clients without manual cookie handling.
For same-origin browser apps with existing session auth: this is the easiest path.
Pattern 2: Token in query parameter
const ws = new WebSocket(`wss://example.com/ws?token=${jwt}`);
@app.websocket("/ws")
async def ws(websocket: WebSocket):
token = websocket.query_params.get("token")
if not token or not valid_jwt(token):
await websocket.close(code=4001)
return
await websocket.accept()
Pros:
- Simple; works in any client.
- No cookies required.
Cons:
- Token in URL: ends up in server access logs, browser history, referer headers (less of an issue for WS but still).
- Mitigation: short-lived ticket tokens (one-use; see Pattern 5).
- Tokens visible in
ps, container logs, monitoring.
This pattern is everywhere despite the leak concern. Acceptable for short-lived tokens; risky for long-lived.
Pattern 3: Subprotocol as auth carrier
const ws = new WebSocket("wss://example.com/ws", ["bearer", "eyJhbG..."]);
Browsers DO let you set Sec-WebSocket-Protocol from JS. Abuse this to send auth:
@app.websocket("/ws")
async def ws(websocket: WebSocket):
protocols = websocket.headers.get("sec-websocket-protocol", "").split(", ")
if len(protocols) < 2 or protocols[0] != "bearer":
await websocket.close(code=4001)
return
token = protocols[1]
if not valid_jwt(token):
await websocket.close(code=4001)
return
await websocket.accept(subprotocol="bearer") # echo back the "real" subprotocol
Pros:
- Token not in URL.
- Header-like delivery from the browser.
Cons:
- Hacky; mixing the subprotocol negotiation field with auth.
- Some proxies / WAFs treat subprotocol values opaquely; some don’t.
- Token still on the wire (encrypted via wss://, but a captured handshake reveals it).
Used by some major apps (e.g., Kubernetes’ API).
Pattern 4: Send token in first message after connection
const ws = new WebSocket("wss://example.com/ws");
ws.onopen = () => ws.send(JSON.stringify({ type: "auth", token: jwt }));
@app.websocket("/ws")
async def ws(websocket: WebSocket):
await websocket.accept()
try:
# Set a timeout for auth
auth_msg = await asyncio.wait_for(websocket.receive_json(), timeout=5.0)
except asyncio.TimeoutError:
await websocket.close(code=4001, reason="Auth timeout")
return
if auth_msg.get("type") != "auth" or not valid_jwt(auth_msg.get("token")):
await websocket.close(code=4001)
return
# ... now do work as the authenticated user
Pros:
- Token not in URL, not in headers.
- Can carry richer auth payloads (refresh tokens, MFA proof, etc.).
Cons:
- Connection accepted before auth — counts toward connection limits regardless.
- Slightly more complex; need timeout to prevent DoS by idle unauthenticated connections.
- Bot-friendly: opens connection, never sends auth, ties up resources until timeout.
Combine with rate limits + short auth timeout to mitigate the DoS angle.
Pattern 5: Ticket / signed-URL pattern
The most robust pattern for production browser apps. Two steps:
- Browser makes an authenticated HTTP request to get a short-lived “ticket”:
POST /api/ws/ticket
Cookie: session=...
→
{ "ticket": "eyJhbG...", "expires_in": 30 }
- Browser uses the ticket in the WebSocket URL:
const { ticket } = await fetch("/api/ws/ticket").then(r => r.json());
const ws = new WebSocket(`wss://example.com/ws?ticket=${ticket}`);
@app.websocket("/ws")
async def ws(websocket: WebSocket):
ticket = websocket.query_params.get("ticket")
user = await validate_and_consume_ticket(ticket)
if not user:
await websocket.close(code=4001)
return
await websocket.accept()
# ...
Pros:
- The “URL token” is short-lived and one-use; even if logged, useless within seconds.
- Real auth happens on the HTTP endpoint (full HTTP auth machinery available).
- Standard pattern; well-understood.
Cons:
- Two requests (HTTP + WS) for connect.
- Need a ticket store (Redis with TTL is common).
- Slight added complexity.
This is the modern best practice for browser WebSocket auth.
JWT in the query — short-lived only
If you’re using JWT directly in the query (Pattern 2 without the ticket indirection), make the JWT short-lived (≤ 60s) and dedicated:
def issue_ws_token(user_id):
return jwt.encode({
"sub": user_id,
"aud": "websocket", # narrow audience
"exp": now() + 60, # 60s lifetime
"iat": now(),
}, secret, algorithm="HS256")
aud: "websocket" so even if leaked, the token isn’t accepted by your other APIs.
Re-authentication during the connection
Long-lived WebSocket connections (hours) need to handle:
- Token expiry: if the access token expires mid-connection, what happens?
- Permission change: user’s role changed; their WS session might still have admin powers.
- Logout: the user clicked logout in another tab.
Approaches:
- Connection-bound auth: auth happens at connect time; long-lived sessions don’t re-check. Simple; security debt for stale sessions.
- Periodic re-auth: server requests a fresh token every N minutes; client responds with
{type: "auth", token: ...}. Adds complexity but bounds the stale-session window. - Server-side push of revocation: on logout, server force-closes that user’s WebSocket. Requires session → connection mapping.
For sensitive applications: combine #2 (re-auth periodically) + #3 (server-push close on logout).
Auth in Django Channels
# Channels uses Django's AuthMiddlewareStack
# In asgi.py:
application = ProtocolTypeRouter({
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),
})
# In your consumer:
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
if not self.scope["user"].is_authenticated:
await self.close(code=4001)
return
await self.accept()
AuthMiddlewareStack uses Django’s session middleware to populate self.scope["user"]. For JWT-based auth, write a custom middleware:
class JWTMiddleware:
def __init__(self, inner):
self.inner = inner
async def __call__(self, scope, receive, send):
token = parse_query_string(scope["query_string"]).get("token")
try:
scope["user"] = await get_user_from_jwt(token)
except Exception:
scope["user"] = AnonymousUser()
return await self.inner(scope, receive, send)
Non-browser clients
For Python or other server-side clients, set headers normally:
from websockets.asyncio.client import connect
async with connect(
"wss://example.com/ws",
additional_headers={"Authorization": "Bearer eyJ..."},
) as ws:
...
No need for the browser workarounds. Use real HTTP Authorization headers.
Pitfalls
- Token never expires: a 1-hour WebSocket session with a 30-day JWT is overkill; if leaked, bad. Use short-lived ticket tokens.
- No
Originvalidation: any cross-origin page can open a WebSocket using the user’s cookies (CSWSH). See 06_security.md. - Accepting before auth check: connection counts against limits; bot can open many idle connections.
SameSite=Laxcookies on cross-origin WebSocket: cookies aren’t sent. Either same-origin orSameSite=None; Secure.- Reusing access tokens forever: token leaked = forever vulnerable. Implement re-auth or short tokens.
Common interview confusions
- “You can set Authorization headers from browser JS in WebSocket.” — no. Browsers don’t expose headers in the WebSocket constructor (security and consistency reasons).
- “Cookies don’t work over WebSocket.” — they do. Cookies set for the origin are sent on the handshake.
- “JWT in query param is fine.” — it’s leaked to logs / history. Use short-lived ticket-style tokens, not your normal access tokens.
Interview angle
- “How do you authenticate a WebSocket from a browser?” — five patterns: cookies (if same-site session), token in URL query (risky for long-lived tokens), subprotocol carrier (hacky but works), first-message auth (DoS risk without timeout), ticket pattern (best — short-lived signed URL from authenticated HTTP).
- “Why can’t you put
Authorization: Bearerin the WebSocket handshake from browsers?” — browsers don’t expose headers on the WebSocket constructor. Only cookies (automatic), URL params, and subprotocol values are available. - “What’s the ticket pattern?” — two-step auth: authenticated HTTP request returns a short-lived (~30s) one-use ticket; browser uses ticket in WS URL; server validates + consumes the ticket. Real auth machinery on HTTP; the URL exposure is short-lived and bounded.
- “How does Django Channels handle auth?” —
AuthMiddlewareStackreads Django’s session cookie, populatesself.scope["user"]. For JWT, write a custom middleware that parses the token and setsscope["user"]. - “What happens if a JWT expires during a WebSocket session?” — depends on your design. Simplest: nothing (session-bound auth). Better: periodic re-auth where server requests fresh token; on failure, close connection.
- “How do you handle logout when the user has an open WebSocket?” — server-side: track user → active connections; on logout, force-close their WebSockets with code 4001. Otherwise their session remains active server-side until the connection ends.
- “Cookies vs query token vs ticket — which for a B2B SaaS WebSocket?” — ticket pattern. Same-origin cookies work for browser-only same-domain apps; query tokens leak to logs; subprotocol is hacky. Ticket gives short-lived URL exposure with real HTTP auth behind it.