channels-jwt-subprotocol
Authenticate Django Channels WebSockets with a JWT sent as a subprotocol, so the token never appears in the URL.
A browser cannot set an Authorization header on a WebSocket handshake. The usual workaround puts
the token in the query string, wss://example.com/ws/?token=…, and the query string is part of the
request line that nginx, load balancers and CDNs write to their access logs by default. An access
token lives for minutes; a log line lives for months.
The Sec-WebSocket-Protocol header is the other place a browser lets you put a value, and access
logs do not record it by default. This package reads the token from there, validates it with
SimpleJWT (or any check you supply),
and handles the two handshake details that otherwise fail silently:
- 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 middleware records a fixed marker, and the consumer helpers echo the marker, never the token.
- A close code sent before
accept()never reaches the client. Daphne and Uvicorn answer the upgrade with an HTTP 403 instead, and the browser sees code 1006, the same as a dropped network.refuse()accepts first and then closes, so the client receives4401and 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.
Use
Client. Offer the marker jwt, then the token:
const ws = new WebSocket('wss://example.com/ws/feed/', ['jwt', accessToken])
A JWT is made of base64url segments and dots, which are all valid subprotocol characters. An invalid
value makes the constructor throw a SyntaxError, so treat a throw like a failed connect.
ASGI. Wrap your WebSocket router:
# 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:
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"})
JWTSubprotocolMixin is the same for the synchronous WebsocketConsumer and
JsonWebsocketConsumer.
Client, on close. 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 own first message arrives, and
renew the token on 4401:
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
}
If a freshly renewed token is refused too, back off instead of renewing again, or a deactivated account turns into a loop against your refresh endpoint.
Behaviour
| Client offers | scope["user"] |
Consumer with refuse() |
|---|---|---|
["jwt", <valid access token>] |
the token's user | accepted, echoes jwt |
["jwt", <expired, refresh, garbage token>] |
AnonymousUser |
accepted with jwt, closed 4401 |
["jwt"] |
AnonymousUser |
accepted with jwt, closed 4401 |
nothing, or no jwt marker |
unchanged, or AnonymousUser if unset |
accepted, closed 4401 |
The token is the value immediately after the marker, so a client can offer an application
protocol too: ['graphql-transport-ws', 'jwt', token]. If you do, accept with that protocol yourself,
as the server may echo only one value.
When the marker is offered, the token decides the user, even if an earlier middleware already 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, no second implementation to drift.
Options
JWTSubprotocolMiddleware(inner, marker="jwt", get_user=None)
marker: the fixed value the client offers before the token. Use the same one in the client.get_user: an async callableget_user(raw_token) -> user. ReturnAnonymousUser()to refuse. Use it for a non-SimpleJWT token, or to add your own checks.
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=...).
What this 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. Renew near-expiry tokens before connecting, and close sockets yourself if your app needs revocation to take effect immediately.
- It is not encryption. Use
wss://. The point is that the token is not written to logs by default, not that it is hidden from anything on the connection. - Check your own logging. A proxy configured to log request headers will log this one too.
Tests
pip install -e ".[test]"
pytest
The suite includes live tests that start Daphne and Uvicorn on a local port and connect with the
websockets client, which is how the HTTP 403 for a close before accept(), and the 4401 that
arrives after accepting, are checked against real servers rather than assumed. They pass on Daphne
4.0 and 4.2, Uvicorn 0.30 and 0.54, Channels 4.0 and 4.3.
Background
Written for the dashboard of TheLoops, where one socket per tab pushes the status of 24/7 streams. The long version, including the nginx and idle-timeout parts, is in Django Channels: JWT auth without the token in the URL.
License
MIT
Metadata
Release files for channels-jwt-subprotocol 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| channels_jwt_subprotocol-1.0.0.tar.gz | 13.7 kB | With provenance | Details for channels_jwt_subprotocol-1.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| channels_jwt_subprotocol-1.0.0-py3-none-any.whl | Python 3 | none | any | Details for channels_jwt_subprotocol-1.0.0-py3-none-any.whl |
Total release size: 22.9 kB
Release files / channels_jwt_subprotocol-1.0.0.tar.gz
| Download URL | channels_jwt_subprotocol-1.0.0.tar.gz |
|---|---|
| Size | 13.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8a0686e2910a019d1b77e1216456bbd51e779c868b409a1b6bfcc96e3612e824
|
|
BLAKE2b-256 checksum How to use checksums |
26ea3fad2bfb19bb7218f41d0223dded32e714cdb68c072b05acab5418de7cb9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 10, 2026.
Transparency logRelease files / channels_jwt_subprotocol-1.0.0-py3-none-any.whl
| Download URL | channels_jwt_subprotocol-1.0.0-py3-none-any.whl |
|---|---|
| Size | 9.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b1e116a90da066d25ccb486f017fb1bdeba271e0dd1084b822b6e64fb47132a1
|
|
BLAKE2b-256 checksum How to use checksums |
5dda75fec6ba5b5d29fc1e8812e185657b0ee0ba401630fa17edf8992c7b51ee
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 10, 2026.
Transparency logSecurity for channels-jwt-subprotocol 1.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for channels_jwt_subprotocol-1.0.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 10, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.