Authenticating WebSocket Connections¶
Browsers cannot set an Authorization header on a WebSocket, which is why WebSocket authentication is less obvious than for HTTP. There are three workable places for the credential: the handshake request (a cookie, a query parameter, or a header from non-browser clients), the first message after the connection opens, or a short-lived ticket obtained over HTTP. Tested with websockets 17.1: rejecting a missing token during the handshake returned HTTP 401 to the client and cost 0.48 ms per rejected attempt; authenticating with the first message cost 0.71 ms per rejected attempt, because the connection had to be accepted first, and a client that never sent its auth message was closed with code 4401 after the 2.0 s deadline. This guide implements both, chooses between them, and handles what HTTP never had to: credentials that expire while a connection stays open for hours.
Prerequisites¶
- Python 3.11+,
pip install websockets; measured with websockets 17.1. The Starlette equivalent is in handling WebSockets in FastAPI and Starlette. - A token verifier for your identity system (JWT validation, session lookup).
- TLS: every WebSocket carrying credentials must be
wss://.
1. Reject at the handshake when you can¶
The handshake is an HTTP request, so credentials sent with it can be checked before any WebSocket state exists. In websockets, process_request sees the request and can return an HTTP response instead of upgrading:
from http import HTTPStatus
from urllib.parse import parse_qs, urlparse
from websockets.asyncio.server import serve
def authenticate(connection, request):
token = request.headers.get("Authorization", "").removeprefix("Bearer ") or None
if token is None: # browsers: query string or cookie
token = (parse_qs(urlparse(request.path).query).get("token") or [None])[0]
user = verify_token(token)
if user is None:
return connection.respond(HTTPStatus.UNAUTHORIZED, "invalid token\n")
connection.user = user # available in the handler
async def handler(ws):
await ws.send(f"hello {ws.user}")
async with serve(handler, "0.0.0.0", 8765, process_request=authenticate):
await asyncio.get_running_loop().create_future()
Tested: a header token and a query-string token were both accepted, and a connection with no token received a 401 response — the client library raised InvalidStatus with status 401 rather than opening a socket. Rejection cost 0.48 ms per attempt. Handshake rejection is cheapest and means unauthenticated clients never hold a WebSocket, never reach the handler and never count against connection limits.
Verify: curl -i -H "Connection: Upgrade" -H "Upgrade: websocket" ... without a token gets 401, not 101 Switching Protocols.
2. Keep tokens out of logs when using the query string¶
Browsers can put a credential in the URL — new WebSocket("wss://host/ws?token=...") — but URLs end up in access logs, proxy logs and browser history. Use a short-lived, single-purpose ticket instead of the user's main token:
import secrets
import time
TICKETS: dict[str, tuple[str, float]] = {} # use Redis with a TTL across instances
async def issue_ticket(request): # plain HTTP endpoint, normal auth applies
user = request.state.user
ticket = secrets.token_urlsafe(32)
TICKETS[ticket] = (user.id, time.monotonic() + 30) # valid for 30 s, once
return JSONResponse({"ticket": ticket})
def redeem(ticket: str | None) -> str | None:
entry = TICKETS.pop(ticket, None) if ticket else None # pop: single use
if entry and entry[1] > time.monotonic():
return entry[0]
return None
The browser fetches a ticket over its authenticated HTTP session, then opens the WebSocket with ?ticket=... within 30 seconds. A ticket in a log is useless afterwards: it has been used and has expired. Cookies are the other browser-friendly option — they are sent with the handshake automatically — but then the server must check the Origin header, because a cookie-authenticated WebSocket is otherwise open to cross-site WebSocket hijacking:
ALLOWED_ORIGINS = {"https://app.example.com"}
def check_origin(connection, request):
if request.headers.get("Origin") not in ALLOWED_ORIGINS:
return connection.respond(HTTPStatus.FORBIDDEN, "bad origin\n")
websockets also accepts an origins=[...] argument to serve that does this check for you.
Verify: a ticket works once and fails the second time; a connection with a valid cookie from a foreign Origin is refused with 403.
3. Authenticate on the first message when the handshake cannot carry it¶
Some clients and proxies make handshake credentials awkward. Then accept the connection, require an auth message first, and bound the wait:
async def handler(ws):
try:
async with asyncio.timeout(2.0):
hello = json.loads(await ws.recv())
except (TimeoutError, ValueError):
await ws.close(4401, "auth required")
return
user = verify_token(hello.get("token"))
if user is None:
await ws.close(4401, "invalid token")
return
await serve_user(ws, user)
Tested: a client that connected and sent nothing was closed with code 4401 and reason "auth required" after 2.00 s. The deadline is essential — without it, unauthenticated sockets stay open indefinitely and are a cheap way to exhaust connection slots. Close codes 4000–4999 are reserved for applications, so 4401 and 4403 mirror HTTP's 401 and 403 in a way clients can handle programmatically. Until authentication succeeds, the handler must not subscribe the connection to anything or send any data.
Verify: a silent client is disconnected at the deadline; a client that sends any other message first is disconnected with 4401.
4. Handle credentials that expire mid-connection¶
An HTTP request's credentials are checked once and the request is over in milliseconds. A WebSocket can live for hours, outliving the token that opened it. Track expiry and act on it:
async def serve_user(ws, user) -> None:
async def expiry_guard():
while True:
remaining = user.token_expires_at - time.time()
if remaining <= 0:
await ws.close(4401, "token expired")
return
await asyncio.sleep(min(remaining, 60))
async def receive_loop():
async for raw in ws:
msg = json.loads(raw)
if msg.get("type") == "reauth": # client refreshes in-band
refreshed = verify_token(msg["token"])
if refreshed is None or refreshed.id != user.id:
await ws.close(4401, "invalid token")
return
user.token_expires_at = refreshed.expires_at
continue
await handle(user, msg)
async with asyncio.TaskGroup() as tg:
guard = tg.create_task(expiry_guard())
await receive_loop()
guard.cancel()
The client either sends a fresh token before expiry or is disconnected and reconnects with a new one; both are standard. Revocation — a logout, a disabled account — needs the server to close connections actively: keep a map from user id to open connections and close them when an account is revoked, across instances via the pub/sub channel from scaling WebSockets across processes with Redis pub/sub.
Verify: a connection opened with a token that expires in one minute is closed with 4401 at expiry unless the client re-authenticates.
5. Authorize every subscription and message¶
Authentication says who the user is; each action still needs authorization. A WebSocket that lets an authenticated user subscribe to any channel by name is an open data feed:
async def handle(user, msg) -> None:
if msg["type"] == "subscribe":
channel = msg["channel"]
if not await can_read(user, channel): # e.g. "orders:<account_id>"
await send_error(user, "forbidden", channel)
return
await subscribe(user, channel)
Check permissions on every subscribe and every write message, using the same authorization rules as the HTTP API, ideally the same code. Rate-limit messages per connection, because an authenticated client can still flood the server. And log the user id with every connection open and close, so security reviews can reconstruct who was connected when.
Verify: an authenticated user who requests another account's channel gets a forbidden error, in a test that runs on every build.
Verification¶
WebSocket authentication is complete when:
- Credentials are checked at the handshake where possible, with 401 or 403 before upgrade.
- Browser credentials are short-lived tickets or Origin-checked cookies, never long-lived tokens in URLs.
- First-message auth has a deadline, and nothing is sent before it succeeds.
- Expiry, revocation and per-message authorization are enforced on open connections.
Diagnostic Hook: count handshake rejections by reason, 4401 closes by reason, and connections whose token has expired but are still open. The last number should be zero; anything else means the expiry guard is missing on some path.
Pitfalls & edge cases¶
- Long-lived tokens in query strings. They end up in logs; use single-use tickets.
- Cookie auth without an Origin check. Cross-site WebSocket hijacking.
- First-message auth without a deadline. Unauthenticated sockets accumulate.
- Authenticating once per connection. Tokens expire and accounts are revoked while sockets stay open.
Frequently Asked Questions¶
How do I authenticate a WebSocket connection from a browser?
Fetch a short-lived single-use ticket over your authenticated HTTP API and pass it in the WebSocket URL, or rely on a session cookie and check the Origin header in the handshake. Browsers cannot set an Authorization header on WebSockets.
How do I reject an unauthenticated WebSocket in the Python websockets library?
Pass process_request to serve and return connection.respond(HTTPStatus.UNAUTHORIZED, ...) when the token is missing or invalid. The client receives an HTTP 401 instead of an upgrade; in testing this cost 0.48 ms per attempt.
Is it safe to authenticate with the first WebSocket message?
Yes, if you require it within a short deadline and send nothing before it succeeds. In testing, a silent client was closed with code 4401 after the 2 s deadline.
What happens when a WebSocket user's token expires?
Nothing, unless you handle it. Track the expiry per connection and close with 4401 when it passes, or let the client send a refreshed token in-band before it does.
Related¶
- WebSocket & Real-Time Streams — up to the topic overview.
- Testing WebSocket servers — tests for the 401, 4401 and expiry paths.
- Network I/O & Protocol Handling — the section overview.