Fix Django Channels WebSocket 403 Forbidden in Production
Fix Django Channels WebSocket connections rejected with 403 or closed with code 1006 in production: configure AllowedHostsOriginValidator and ALLOWED_HOSTS, pass Host and Origin through Nginx, and check authentication middleware and CSRF-like origin rules.
Problem statement
WebSockets work locally, but in production the browser console shows:
WebSocket connection to 'wss://app.example.com/ws/notifications/' failed:
Error during WebSocket handshake: Unexpected response code: 403
and Daphne or Uvicorn logs:
WSREJECT /ws/notifications/ [203.0.113.7:51234]
The handshake reached Django Channels, and Channels refused it. The usual reason is AllowedHostsOriginValidator, which compares the browser's Origin header with ALLOWED_HOSTS and rejects anything that does not match. Behind a proxy, a missing or rewritten Host or Origin header has the same effect. Less often, your consumer calls self.close() because the user is not authenticated.
- Make sure the site's domain is in
ALLOWED_HOSTS, exactly as the browser sees it. - Wrap the router in
AllowedHostsOriginValidator(keep it) and confirm the Origin matches. - In Nginx, forward
Host $hostand keep theOriginheader, plusUpgradeandConnection. - If the frontend runs on another domain, add that domain to
ALLOWED_HOSTSor useOriginValidatorwith an explicit list. - Check the consumer: a
close()inconnect()for anonymous users also produces 403.
Step-by-step solution
1. Check the ASGI routing
A typical asgi.py:
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
django_asgi_app = get_asgi_application()
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from notifications.routing import websocket_urlpatterns
application = ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": AllowedHostsOriginValidator(
AuthMiddlewareStack(URLRouter(websocket_urlpatterns))
),
})
AllowedHostsOriginValidator is a security feature: it stops other websites from opening WebSockets to your app with the user's cookies (cross-site WebSocket hijacking). Keep it, and make the Origin pass.
2. Match ALLOWED_HOSTS to the Origin
The validator takes the host from the Origin header (for example https://app.example.com becomes app.example.com) and checks it against ALLOWED_HOSTS, with the same rules as Django's host validation:
ALLOWED_HOSTS = ["app.example.com", ".example.com"]
If the frontend is a separate SPA on https://www.example.com connecting to wss://api.example.com, the Origin is www.example.com, which must also be allowed. For an explicit list independent of ALLOWED_HOSTS, use OriginValidator(app, ["https://www.example.com"]).
3. Forward headers through Nginx
location /ws/ {
proxy_pass http://asgi;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s;
}
Nginx forwards Origin unchanged by default. Do not set proxy_set_header Origin "" or override it with the upstream address. If Host arrives as asgi or 127.0.0.1:8001, host checks inside consumers and URL building break as well.
4. Check authentication in the consumer
class NotificationConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
if self.scope["user"].is_anonymous:
await self.close() # the client sees 403
return
await self.accept()
If session cookies do not reach the WebSocket, every user looks anonymous. Common causes: the WebSocket connects to a different domain than the one that set the cookie, or SESSION_COOKIE_DOMAIN does not cover both. Log self.scope["user"] and self.scope["headers"] in connect() while debugging.
5. Test the handshake from the command line
curl -i -N \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
-H "Origin: https://app.example.com" \
https://app.example.com/ws/notifications/
101 Switching Protocols means the origin and routing pass. 403 with your real origin means the validator or consumer rejects it; try with the origin removed to compare.
Explanation
Browsers do not apply the same-origin policy or CORS to WebSockets. Any page can open a WebSocket to any server, and the browser attaches that server's cookies. The Origin header is the only signal that tells the server which site initiated the connection, so Channels provides validators to check it. In production, the domain names differ from local ones, and proxies sit in between, which is why the validator rejects connections that worked on localhost.
Edge cases / notes
- Mobile apps and scripts. Non-browser clients may send no
Origin.AllowedHostsOriginValidatorrejects those; authenticate them with tokens on a separate route without the validator. - Ports. A frontend dev server on
localhost:3000sendsOrigin: http://localhost:3000; allowlocalhostonly in development settings. - Daphne vs Uvicorn. Both log rejected handshakes differently, but the 403 comes from Channels in either case.
Next steps
- If the handshake never reaches Django, see Fix Django WebSocket Connection Failed Behind Nginx.
- Review the deployment with Deploy Django Channels With Daphne and WebSockets.
- Check the rest with the Django Channels Production Checklist.
FAQ
Should I remove AllowedHostsOriginValidator to fix it?
No. It protects against cross-site WebSocket hijacking. Make the Origin match instead.
Why does it work locally?
Locally the Origin is localhost, which is usually in ALLOWED_HOSTS with DEBUG = True. Production uses your real domain.
What does WSREJECT mean in the logs?
Channels received the handshake and refused it, either by a validator or by the consumer closing before accepting.
Do WebSockets use CORS?
No. Browsers do not enforce CORS for WebSockets, which is why the server must check the Origin itself.