Skip to content

Authorization

In the last lesson we settled who is on the connection. This lesson is about the much larger question that follows: is this particular message allowed? Authentication happens once; authorization happens every single message. Conflating the two is one of the most common and most expensive WebSocket bugs.

An HTTP API re-checks permission on each request almost by accident — each request is separate, so each one runs through your middleware. A WebSocket connection breaks that habit. After the handshake, a hundred messages arrive over one authenticated connection, and it is tempting to think “this connection is logged in, so its messages are fine.”

They are not. Logged in is not allowed. A user authenticated as alice may send:

{ "type": "delete", "room": "admins", "messageId": "42" }

Whether alice may delete that message has nothing to do with whether alice is authenticated — it depends on alice’s role, her membership in that room, and her relationship to that specific message. That decision must be made fresh, server-side, on arrival.

Never trust the client’s claimed identity

Section titled “Never trust the client’s claimed identity”

Here is the cardinal sin, and it is everywhere. The message above might instead arrive as:

{ "type": "delete", "user": "admin", "room": "admins", "messageId": "42" }

A naive server reads msg.user and thinks “ah, an admin asked.” But the client wrote that field. A client can claim to be anyone. The only trustworthy identity is the one your server pinned to the connection at the handshakews.user from the previous lesson — established from a validated credential the client could not forge.

So the rule is blunt: ignore any identity in the message body. Authorize against ws.user, the value you set, never against a user/role/isAdmin field the client supplied. If the client could write it, it is a claim, not a fact.

flowchart TB
  M["Incoming message<br/>(may CLAIM a user/role)"] --> I["Ignore claimed identity"]
  I --> ID["Use ws.user — pinned at handshake"]
  ID --> P{"Is ws.user allowed<br/>to do THIS action<br/>on THIS resource?"}
  P -- "yes" --> A["Perform action"]
  P -- "no" --> D["Reject: error frame<br/>(or close on abuse)"]
Per-message authorization against the connection’s pinned identity

The demo below is the body of a ws connection handler. The handshake has already pinned an identity to the socket — here we hard-code ws.user and a role table to keep the demo self-contained. Then every incoming message is authorized: we look up the real role of ws.user, and we deliberately ignore any user field the client sends. A delete is allowed only for admins; a post is allowed for everyone. Open it in StackBlitz and have the client send different message types.

Node.js

Needs the Node.js runtime — open in StackBlitz to run.

Because ws.user is alice, a member, a post is allowed but a delete is denied — even if the client adds "user": "admin" to the message, because the server never reads that field. Flip ws.user to 'bob' and the delete is allowed. That is the whole discipline: identity comes from the connection, permission is recomputed per message, and the message body is treated as a request, not as a source of truth.

How often must authorization run on a WebSocket connection?
A client sends { "type": "delete", "user": "admin" }. What should the server authorize against?
Why is authenticating the connection not sufficient to allow an action?