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.
Authenticate once, authorize every action
Section titled “Authenticate once, authorize every action”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 handshake — ws.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)"] A runnable per-message authorization demo
Section titled “A runnable per-message authorization demo”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.
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.