backend / protocols / websockets / 04_authentication.md

WebSocket Authentication

7 interview angles 7 min read source

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.

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; Secure cookies.
  • 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:

  1. Browser makes an authenticated HTTP request to get a short-lived “ticket”:
POST /api/ws/ticket
Cookie: session=...

{ "ticket": "eyJhbG...", "expires_in": 30 }
  1. 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:

  1. Connection-bound auth: auth happens at connect time; long-lived sessions don’t re-check. Simple; security debt for stale sessions.
  2. 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.
  3. 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 Origin validation: 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=Lax cookies on cross-origin WebSocket: cookies aren’t sent. Either same-origin or SameSite=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: Bearer in 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?”AuthMiddlewareStack reads Django’s session cookie, populates self.scope["user"]. For JWT, write a custom middleware that parses the token and sets scope["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.