Перейти до основного вмісту
Version 1.0.0 · updated 2026-11-10GitHubPyPI

channels-jwt-subprotocol

Authenticate Django Channels WebSockets with a JWT sent as a subprotocol, so the token never appears in the URL or in access logs. MIT licensed.

A browser cannot set an Authorization header on a WebSocket handshake, so the usual workaround puts the token in the query string. The query string is part of the request line that nginx, load balancers and CDNs write to their access logs by default. This package reads the token from the Sec-WebSocket-Protocol header instead, validates it with SimpleJWT or any check you supply, and handles the two handshake details that otherwise fail without an error.

  • The server must echo a subprotocol the client offered. Echo nothing and the browser drops the connection; echo the token and it ends up in a response header. The consumer helpers echo a fixed marker, never the token.
  • A close code sent before accept() never reaches the client. The server answers the upgrade with an HTTP 403 and the browser sees 1006, the same as a dropped network. refuse() accepts first and then closes, so the client receives 4401 and knows to renew its token.

Install

pip install "channels-jwt-subprotocol[simplejwt]"

Python 3.10+, Django 4.2+, Channels 4.0+. Leave out [simplejwt] if you validate tokens yourself.

Client

Offer the marker jwt, then the token:

const ws = new WebSocket('wss://example.com/ws/feed/', ['jwt', accessToken])

A JWT is base64url segments and dots, all valid subprotocol characters. An invalid value makes the constructor throw a SyntaxError, so treat a throw like a failed connect.

ASGI

# asgi.py
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application

django_asgi_app = get_asgi_application()

from channels_jwt_subprotocol import JWTSubprotocolMiddleware  # noqa: E402
from myapp.routing import websocket_urlpatterns  # noqa: E402

application = ProtocolTypeRouter({
    "http": django_asgi_app,
    "websocket": AllowedHostsOriginValidator(
        JWTSubprotocolMiddleware(URLRouter(websocket_urlpatterns))
    ),
})

Consumer

Use the mixin's accept_authenticated() and refuse() instead of calling accept() and close() yourself. JWTSubprotocolMixin is the same for the synchronous consumers.

from channels.generic.websocket import AsyncJsonWebsocketConsumer
from channels_jwt_subprotocol import AsyncJWTSubprotocolMixin


class FeedConsumer(AsyncJWTSubprotocolMixin, AsyncJsonWebsocketConsumer):
    async def connect(self):
        if not self.is_authenticated():
            return await self.refuse()          # accept, then close with 4401
        await self.accept_authenticated()       # echoes "jwt", never the token
        await self.send_json({"type": "connected"})

Because the server accepts before it refuses, onopen fires for a socket that is about to be closed. Treat the connection as live when the server's first message arrives, and renew the token on 4401. If a freshly renewed token is refused too, back off instead of renewing again.

ws.onmessage = (e) => {
  if (JSON.parse(e.data).type === 'connected') markLive()
}
ws.onclose = (e) => {
  if (e.code === 4401) return renewTokenThenReconnect()   // refused: a new token may help
  scheduleRetry()                                         // network: back off and retry
}

Behaviour

Client offersscope["user"]With refuse()
["jwt", valid access token]the token's useraccepted, echoes jwt
["jwt", expired, refresh or garbage token]AnonymousUseraccepted with jwt, closed 4401
["jwt"]AnonymousUseraccepted with jwt, closed 4401
nothing, or no jwt markerunchanged, or AnonymousUser if unsetaccepted, closed 4401

The token is the value immediately after the marker, so a client can offer an application protocol too. When the marker is offered, the token decides the user even if an earlier middleware set one; when it is not, an existing scope["user"] is left alone, so the middleware can sit inside AuthMiddlewareStack for apps that also use session cookies.

The default check is SimpleJWT's JWTAuthentication, the class that already guards your REST API: same signing key, expiry, token type and is_active rule.

Options

JWTSubprotocolMiddleware(inner, marker="jwt", get_user=None). get_user is an async callable that takes the raw token and returns a user, or AnonymousUser() to refuse:

from channels.db import database_sync_to_async
from django.contrib.auth.models import AnonymousUser


@database_sync_to_async
def get_user(raw_token):
    user = verify_with_your_identity_provider(raw_token)
    return user or AnonymousUser()


application = JWTSubprotocolMiddleware(router, get_user=get_user)

The refusal code is CLOSE_UNAUTHENTICATED (4401). Override close_unauthenticated_code on the consumer, or pass refuse(code=...).

Tested against real servers

The test suite starts Daphne and Uvicorn on a local port and connects with the websockets client, so the two handshake claims above are checked on the wire, not assumed. It runs on the oldest and newest supported versions:

# Daphne 4.0 and 4.2, Uvicorn 0.30 and 0.54, websockets client
close(code=4401) before accept()  ->  HTTP 403, the code is lost
refuse()                          ->  101, subprotocol "jwt", then close code 4401

What it does not do

  • It authenticates the handshake only. A socket opened with a token that has two minutes left stays open after the token expires; close sockets yourself if revocation must take effect at once.
  • It is not encryption. Use wss://.
  • A proxy configured to log request headers will log this one too.

The background, with the nginx upgrade headers and idle timeouts, is in Django Channels: JWT auth without the token in the URL. Source, issues and the changelog are on GitHub.

Built for the dashboard of TheLoops, a service that streams pre-recorded video 24/7 to YouTube, Twitch and Kick. More engineering notes.