Troubleshooting
ASGI & WebSockets

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.

Why the handshake is rejectedBrowser sendsOrigin and HostProxy forwardsheaders intact?Origin validatorchecks ALLOWED_HOSTSNo matchWSREJECT 403Channels rejects WebSockets whose Origin does not match an allowed host.
  1. Make sure the site's domain is in ALLOWED_HOSTS, exactly as the browser sees it.
  2. Wrap the router in AllowedHostsOriginValidator (keep it) and confirm the Origin matches.
  3. In Nginx, forward Host $host and keep the Origin header, plus Upgrade and Connection.
  4. If the frontend runs on another domain, add that domain to ALLOWED_HOSTS or use OriginValidator with an explicit list.
  5. Check the consumer: a close() in connect() 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"]).

match the situationSituationCause and fixfrontend on another domainOrigin not allowed; addit or OriginValidatorworks on IP, not domaindomain missing inALLOWED_HOSTS; add itproxy rewrites headersOrigin or Host lost; forward themonly logged-out usersconsumer closes; expected behaviorWSREJECT means Channels saw the request and refused itdjango-deployment.com
A connection that never reaches Channels shows up as 502 or 1006, not WSREJECT.

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.

checks in order1Nginx upgrades the requestUpgrade, Connection, Host2Origin validatorOrigin vs ALLOWED_HOSTS3Auth middlewaresession cookie to user4URL routerpath matches a consumer5Consumer accepts101 Switching ProtocolsA rejection at any step returns 403 to the browserdjango-deployment.com
An unmatched route also closes the connection, so check the path too.

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. AllowedHostsOriginValidator rejects those; authenticate them with tokens on a separate route without the validator.
  • Ports. A frontend dev server on localhost:3000 sends Origin: http://localhost:3000; allow localhost only in development settings.
  • Daphne vs Uvicorn. Both log rejected handshakes differently, but the 403 comes from Channels in either case.

Next steps

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.

Keep going