Authenticating Django Channels WebSockets without putting the JWT in the URL
A browser cannot send an Authorization header on a WebSocket handshake. The common fix puts the token in the query string, where every access log keeps a copy. This is the other way, and the four places it can quietly go wrong.
Our dashboard keeps one WebSocket open per tab to push stream status as it changes. The REST API authenticates with a JWT in Authorization: Bearer …, and the obvious plan was to authenticate the socket the same way. The browser's WebSocket constructor does not let you: it takes a URL and a list of subprotocols, and nothing else.
That leaves two places a token can travel in a browser handshake: the query string and the Sec-WebSocket-Protocol header. Most tutorials pick the query string. We picked the header, and this note is what that took with Django Channels behind nginx and Cloudflare.
Why not ?token=
nginx's default access log format records $request, which is the full request line, query string included. So does almost every proxy, load balancer and CDN log in front of it. A token in the URL is written to disk on every connect and reconnect, kept for as long as you keep logs, and shipped to wherever your log pipeline goes. An access token lives for minutes; a log line lives for months, and it is read by people and tools that never needed the token.
A subprotocol travels as a request header, and nginx does not log request headers unless you ask it to. This is not encryption, and TLS still matters. It is simply not leaving a credential where it gets copied by default.
The client: offer two values
The client offers a fixed marker, jwt, and the token itself as a second subprotocol:
const SUBPROTOCOL = 'jwt'
const ws = new WebSocket(`wss://${location.host}/ws/broadcasts/`, [SUBPROTOCOL, accessToken])A JWT is safe to send this way. Subprotocol values must be RFC 6455 tokens, and a JWT is base64url segments joined by dots, all of which are allowed characters. A value that is not a valid token, or the same value twice, makes the constructor throw a SyntaxError instead of failing the handshake, so wrap it in try and treat a throw like any other failed connect.
The handshake is the only moment this connection is authenticated. A socket opened with a token that has two minutes left stays open after the token expires. We renew the access token before connecting when it is close to expiry, and accept that an open socket outlives its token: it is a read-only feed of the user's own data, and the next reconnect is checked again.
The server: a middleware that reads scope["subprotocols"]
Channels parses Sec-WebSocket-Protocol into scope["subprotocols"]. The middleware looks for the marker, takes the other value as the token, and resolves the user through SimpleJWT's own JWTAuthentication. That class already enforces the signing key, the expiry, the token type and is_active, and a second implementation of those rules would be one more place for them to drift.
# ws_auth.py
from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware
from django.contrib.auth.models import AnonymousUser
SUBPROTOCOL_MARKER = "jwt"
@database_sync_to_async
def _user_for_token(raw_token):
# Reuse the REST API's rules (signing key, expiry, token type,
# is_active) instead of decoding the JWT a second way.
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import (
AuthenticationFailed, InvalidToken, TokenError,
)
backend = JWTAuthentication()
try:
return backend.get_user(backend.get_validated_token(raw_token))
except (AuthenticationFailed, InvalidToken, TokenError):
return AnonymousUser()
def _token_from_subprotocols(subprotocols):
values = [str(p) for p in (subprotocols or [])]
if SUBPROTOCOL_MARKER not in values:
return None
return next((v for v in values if v != SUBPROTOCOL_MARKER), None)
class JWTAuthMiddleware(BaseMiddleware):
async def __call__(self, scope, receive, send):
scope = dict(scope)
token = _token_from_subprotocols(scope.get("subprotocols"))
scope["user"] = await _user_for_token(token) if token else AnonymousUser()
return await super().__call__(scope, receive, send)# asgi.py
application = ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": JWTAuthMiddleware(URLRouter(websocket_urlpatterns)),
})Echo the marker, never the token
If a client offers subprotocols, the server has to pick one of them and send it back in the response's Sec-WebSocket-Protocol. If it sends nothing, the browser fails the connection: Chrome reports that it sent a non-empty Sec-WebSocket-Protocol header and received no response. If the server echoes the token, the handshake succeeds and the token now sits in a response header instead, which solves nothing. That is what the marker is for: it is the value the server accepts.
class BroadcastConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
user = self.scope.get("user")
if user is None or not user.is_authenticated:
# Accept, then close: a close before accept() is an HTTP 403
# and its code never reaches the browser (see below).
await self.accept(subprotocol="jwt" if "jwt" in self.scope["subprotocols"] else None)
await self.close(code=4401)
return
await self.channel_layer.group_add(f"broadcasts.user.{user.pk}", self.channel_name)
await self.accept(subprotocol="jwt") # echo the marker, never the token
await self.send_json({"type": "connected"})A close code you send before accept() never arrives
We wanted the client to tell "your token is no longer good, refresh and retry" apart from "the network blipped", so our first version rejected a bad token with await self.close(code=4401) before accepting, and the client refreshed the token when it saw 4401. It never saw 4401.
A close() during the handshake is not a WebSocket close frame. The ASGI server answers the upgrade request with an HTTP 403 and the socket never opens, so the code you passed has nowhere to go. Measured:
# Daphne 4.2.3, Channels 4.3.2, Python websockets client close(code=4401) before accept() -> server rejected WebSocket connection: HTTP 403 accept() then close(code=4401) -> open, then closed with code 4401
In a browser the rejected case shows up as onclose with code 1006, the same code a dropped network gives. For a while our client recovered only because it renews a near-expiry token before every reconnect anyway. That covers expiry and nothing else: a token that still looked fresh to the client but was no longer good on the server, because the account was deactivated or the signing key changed, went round the backoff loop every 30 seconds for as long as the tab stayed open.
So the consumer now accepts first and closes second, as in the snippet above. The 4401 then travels as a real close frame and arrives intact, through nginx and Cloudflare too. Accepting admits nothing: the socket joins no group and is sent nothing before the close. Echo the marker only if the client offered it, or the browser fails the handshake and you are back to 1006.
Accept-then-close has one side effect on the client: onopen now fires for a socket that is about to be refused. Ours used to treat onopen as "connected", mark itself live and re-fetch over REST. It now waits for the server's own connected frame, which is sent only after a successful accept(), and treats onopen as transport only:
ws.onopen = () => { /* transport only: a refusal opens too */ }
ws.onmessage = (e) => {
const frame = JSON.parse(e.data)
if (frame.type === 'connected') markLiveAndResync() // the server took the token
// ...
}
ws.onclose = (e) => {
if (e.code === 4401) {
// Refused. Renew once and reconnect; if the fresh token is refused too,
// back off instead of looping against the refresh endpoint.
return renewThenReconnect()
}
scheduleRetry() // the network; after 3 failed handshakes in a row, renew first
}Two guards keep the refresh path from becoming the new loop. A 4401 straight after a forced renewal means the server refuses even a brand new token, so the client backs off instead of renewing again. And a run of plain handshake failures, which is what an older server or a proxy in the middle would still produce, forces one renewal after the third failure, once per run, so a refused token is ruled out without a refresh call on every network blip.
nginx: the upgrade has to be forwarded
A bare proxy_pass does not forward a WebSocket upgrade. Upgrade and Connection are hop-by-hop headers, so nginx drops them unless you set them again, and it talks HTTP/1.0 to the upstream unless told otherwise. For a long time our /ws/ location was exactly that bare proxy_pass, and nothing looked broken: the dashboard fell back to REST, which is what it is built to do when the socket is down. The live updates simply never happened.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
location /ws/ {
proxy_pass http://django;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 1h;
}Check the handshake itself, not the config. This should print 101 Switching Protocols, with a valid token and, now that the consumer accepts before refusing, with a bad one too: the refusal follows as a close frame. A 403 still proves the upgrade reached Django; with accept-then-close it means something in front of the consumer refused it, such as AllowedHostsOriginValidator on a missing or foreign Origin:
curl -sk -i -N --http1.1 --max-time 6 \ -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ -H "Sec-WebSocket-Protocol: jwt, $TOKEN" \ https://example.com/ws/broadcasts/ | head -6
--http1.1 matters. Over HTTP/2 there is no upgrade, the request reaches Django's HTTP router as a plain GET, and you get a 404 that says nothing about WebSockets.
Cloudflare closes a quiet socket
Behind Cloudflare, a WebSocket that carries nothing for about 100 seconds is closed. A user whose streams are all stopped receives no updates at all, so their socket was reaped and re-opened every couple of minutes, and every reopen cost a REST refresh. A ping from the client every 30 seconds keeps it open:
# consumer
async def receive_json(self, content, **kwargs):
if isinstance(content, dict) and content.get("type") == "ping":
await self.send_json({"type": "pong"})
// client: every 30 s, well inside Cloudflare's ~100 s idle limit
setInterval(() => ws.readyState === WebSocket.OPEN && ws.send('{"type":"ping"}'), 30_000)Keep REST as the truth
Everything the socket pushes can also be fetched from the REST endpoint, and the client re-fetches over REST every time it connects or reconnects, instead of the server sending a snapshot. A socket that slept in a background tab has missed an unknown number of messages, and asking for the current state is the honest way to recover. It also means a broken socket costs freshness, not correctness, which is why the nginx mistake above went unnoticed for as long as it did. Treat that as a reason to test the handshake, not as a reason to skip it.
The channel layer sits on its own Redis database number, separate from the Celery broker, so flushing the broker cannot drop every group and disconnect every open dashboard.
Summary
- Send the JWT as a second subprotocol next to a fixed marker, not in the query string.
- Read it from
scope["subprotocols"]in a middleware and validate it with the same code as your REST API. - Accept with the marker as the subprotocol. Never echo the token.
- A close code sent before
accept()becomes an HTTP 403; the client never sees the code. Accept, then close with it. - If you accept before refusing, mark the client live on a frame the server sends after accepting, not on
onopen. - Forward
UpgradeandConnectionin nginx, and test withcurl --http1.1. - Ping every 30 seconds if anything idle-closes your connections.
We run this in production behind TheLoops, a service that streams pre-recorded video 24/7 to YouTube, Twitch and Kick; the dashboard socket described here is how a stream's status reaches the page.