Skip to content

Fix WebSocket 403 Forbidden Errors During Handshake

The 403 is one of the more confusing WebSocket errors because it happens in the gap between HTTP and WebSocket. Your client sends an HTTP upgrade request. The server looks at it — the headers, the origin, the cookies, the path — and says no. The WebSocket protocol never starts. There’s no onopen, no onmessage. Just a failed HTTP request with a 403 status code.

This is actually good news. It means you can debug it with the same tools you use for any HTTP problem.

The Origin header is the most common cause of WebSocket 403 errors. Browsers automatically send it on every WebSocket connection, and many server frameworks reject connections where the origin doesn’t match an allowlist.

Open your browser’s Network tab, find the WebSocket request, and look at the Origin header. Then check your server’s configuration.

Express with ws:

const wss = new WebSocket.Server({
server,
verifyClient: (info, cb) => {
const origin = info.origin || info.req.headers.origin;
const allowed = [
"https://yourdomain.com",
"https://staging.yourdomain.com",
];
if (allowed.includes(origin)) {
cb(true);
} else {
cb(false, 403, "Origin not allowed");
}
},
});

Spring Boot:

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(
WebSocketHandlerRegistry registry) {
registry.addHandler(handler(), "/ws")
.setAllowedOrigins(
"https://yourdomain.com",
"https://staging.yourdomain.com"
);
// Do NOT use "*" in production
}
}

A few things catch people off guard:

  • Port matters. https://localhost:3000 and https://localhost:5173 are different origins. During development with a separate frontend dev server, add both.
  • Scheme matters. http://example.com and https://example.com are different origins. A mixed-content upgrade will fail.
  • No Origin header at all. Non-browser clients (Postman, curl, server-to-server) don’t send an Origin header. If your server requires one, these clients get 403.

The second most common cause: your auth token is missing, expired, or in the wrong place. Unlike HTTP APIs where you can send an Authorization header per request, WebSocket only gets one shot — the upgrade handshake.

The browser’s WebSocket API does not let you set custom headers. You have three options for sending credentials:

URL parameter (most common):

const token = await getAuthToken();
const ws = new WebSocket(
`wss://api.example.com/ws?token=${token}`
);

Cookies (automatic if same-origin):

// Cookie is sent automatically with the upgrade request
// if the WebSocket endpoint is same-origin
const ws = new WebSocket("wss://api.example.com/ws");

First message (after connection opens):

const ws = new WebSocket("wss://api.example.com/ws");
ws.onopen = () => {
ws.send(JSON.stringify({ type: "auth", token }));
};

If you’re using URL parameters and getting 403, check:

  • Is the token actually being sent? Log the full URL server-side.
  • Has the token expired between when you fetched it and when the WebSocket connection starts?
  • Is URL-encoding breaking the token? JWTs with special characters need encodeURIComponent().

If token management is consuming more engineering time than your actual application logic, that’s a sign to consider a managed WebSocket service like Ably, Pusher, or PubNub that handles auth handshakes, token renewal, and origin validation for you.

Servers sometimes return the wrong code, but the distinction matters for debugging:

401 Unauthorized means “I don’t know who you are.” No token was sent, or the token format is unrecognizable. The fix is usually sending credentials in the first place.

403 Forbidden means “I know who you are, but you can’t do this.” The token is valid but doesn’t grant access to the requested resource, or a policy (origin check, IP allowlist, WAF rule) is blocking the request regardless of authentication.

If you’re getting 403 with a valid token, the problem isn’t authentication — it’s authorization or policy. Look at origin checks, CORS configuration, and firewall rules.

Web frameworks with built-in CSRF protection can reject WebSocket handshakes. The upgrade request looks like a regular HTTP POST to the middleware, and it doesn’t have a CSRF token.

Django is the most common offender. The fix is to use Django Channels with proper ASGI routing:

# asgi.py — route WebSocket traffic to Channels,
# not through WSGI middleware
from channels.routing import ProtocolTypeRouter
from channels.routing import URLRouter
from channels.auth import AuthMiddlewareStack
from myapp.routing import websocket_urlpatterns
application = ProtocolTypeRouter({
"http": get_asgi_application(),
"websocket": AuthMiddlewareStack(
URLRouter(websocket_urlpatterns)
),
})

If WebSocket requests are hitting your WSGI application instead of the ASGI consumer, Django’s CsrfViewMiddleware will reject them with 403. The routing above ensures WebSocket connections bypass WSGI entirely.

ASP.NET has a similar issue. Middleware order matters — the WebSocket middleware must run before CSRF/antiforgery middleware:

// Program.cs — order matters
app.UseWebSockets(); // Must come before...
app.UseAntiforgery(); // ...antiforgery middleware
app.MapControllers();

If UseAntiforgery() runs first, it rejects the upgrade request before UseWebSockets() ever sees it.

Cloud provider firewalls often block WebSocket upgrade requests because they look unusual to HTTP-focused rule sets. The Connection: Upgrade and Upgrade: websocket headers trigger rules designed to catch suspicious requests.

AWS WAF: The default AWS managed rule groups can block WebSocket upgrades. Check if the AWSManagedRulesCommonRuleSet is matching on the Upgrade header. Create a rule to allow requests where Upgrade equals websocket and place it before managed rule groups.

Cloudflare: If your Cloudflare security level is set to “I’m Under Attack,” all requests get challenged — including WebSocket upgrades, which can’t complete a JavaScript challenge. Either lower the security level for your WebSocket path or add a WAF exception:

  • Go to Security > WAF > Custom rules
  • Create a rule: URI Path contains /ws AND Request Header "Upgrade" equals "websocket"
  • Set action to Skip (skip all remaining custom rules)

Nginx as reverse proxy: If Nginx sits in front of your WebSocket server, it needs explicit upgrade headers passed through:

location /ws {
proxy_pass http://backend;
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-Real-IP $remote_addr;
}

Without proxy_set_header Upgrade and Connection, Nginx strips the upgrade headers and your backend never sees the WebSocket request. It responds to a plain HTTP request with whatever error makes sense — often 403 or 400.

When you hit a WebSocket 403, work through this checklist:

  1. Open the Network tab. Filter by “WS” in Chrome or Firefox. Click the failed request and look at the response headers. The server might include a reason (X-Error, custom headers, or a response body).

  2. Check the Origin header. Compare what the browser sent to what your server expects. Watch for port mismatches and scheme differences.

  3. Test without the browser. Use wscat or websocat to connect without browser security restrictions:

    Terminal window
    # wscat sends no Origin header by default
    npx wscat -c wss://api.example.com/ws?token=YOUR_TOKEN
    # To test with a specific origin
    npx wscat -c wss://api.example.com/ws \
    --header "Origin: https://yourdomain.com"
  4. Check server logs. The 403 reason is almost always logged server-side. Look for “origin,” “forbidden,” “CSRF,” or “unauthorized” in your application logs.

  5. Bypass the CDN temporarily. If you’re behind Cloudflare or AWS CloudFront, connect directly to your origin server to determine if the CDN’s WAF is causing the 403.

  6. Check middleware order. In ASP.NET, Express, and Django, the order middleware runs matters. CSRF, CORS, and auth middleware that runs before the WebSocket handler will reject the upgrade request.

Why does my WebSocket connection return 403 Forbidden?

Section titled “Why does my WebSocket connection return 403 Forbidden?”

A 403 during WebSocket connection means the HTTP upgrade handshake was rejected by the server. The WebSocket protocol never started. The most common cause is origin validation — the browser sends an Origin header automatically, and your server’s WebSocket library checks it against an allowlist. If the origin doesn’t match (including port and scheme differences), you get 403. After ruling out origin issues, check auth tokens, WAF rules, and CSRF middleware. The key insight: debug this as an HTTP problem, because that’s exactly what it is at this stage.

What is the difference between WebSocket 401 and 403?

Section titled “What is the difference between WebSocket 401 and 403?”

A 401 says “who are you?” — credentials were missing or unrecognizable. A 403 says “I know who you are, but no.” The distinction matters for debugging. If you’re getting 403 and your token is valid, stop looking at authentication code and start looking at authorization policies: origin allowlists, IP restrictions, WAF rules, and CSRF middleware. These are the systems that return 403 even when authentication succeeds. One common confusion: some servers return 403 when they mean 401 (no token sent). If you’re not sure which case you’re in, check your server logs for the actual rejection reason.

How do I fix WebSocket 403 errors with Cloudflare?

Section titled “How do I fix WebSocket 403 errors with Cloudflare?”

Cloudflare’s WAF applies its rules to the HTTP upgrade request. If your security level is high or you have custom rules that match on unusual headers, the upgrade gets blocked. The fix: create a WAF exception rule in Security > WAF that skips rules for requests to your WebSocket path with Upgrade: websocket. Also check that Cloudflare’s WebSocket support is enabled for your plan — free plans support WebSockets, but the feature must be active. For more detail on Cloudflare WebSocket configuration, see our Cloudflare infrastructure guide.

Why does Django return 403 on WebSocket connections?

Section titled “Why does Django return 403 on WebSocket connections?”

Django Channels handles WebSocket connections through ASGI, which bypasses Django’s WSGI middleware stack (including CSRF protection). But this only works if your routing is correct. If the WebSocket URL pattern isn’t matched by your ASGI router, the request falls through to the WSGI application, where CsrfViewMiddleware rejects it. Check your asgi.py routing, make sure your URL patterns match, and verify you’re running under an ASGI server (Daphne or Uvicorn), not a WSGI server (Gunicorn without the ASGI worker).