The Bug That Only Appears When Someone Lies: Authenticating a WebSocket in a Desktop App
Some bugs only show up when you ask "what if the client lies to me?" This is one of those. It's a story about two transport layers that look similar but behave completely differently — and how fixing a real security hole on one side silently broke a feature on the other.
The setup
A desktop app is, under the hood, a web app wrapped in a native shell. That shell usually gives you two different ways of talking to the same backend, and it's easy to forget they don't behave the same way:
- The browser layer (the visible page) automatically carries the user's session with every request. Cookies just work — you don't think about them.
- The background layer (a Node.js process inside the app, handling things like real-time notifications) is not a browser. It has no cookie jar. It sends exactly what you tell it to send, and nothing more.
That split is the whole story.
The original design — and why it felt fine
Real-time systems typically open a persistent connection and pass along a bit of context when connecting: what page you're on, what you're viewing, who you are. In this system, "who you are" started out as just another context hint — sent as plain text by the client when it opened the connection.
For everyday use, this worked flawlessly. The client filled in the correct identity, messages got routed to the right person, and nobody noticed anything was wrong. The design quietly assumed the client tells the truth — which sounds reasonable, because you already had to be logged in just to reach the server at all.
The flaw: hard shell, soft interior
"You're logged in, so I'll trust whatever you claim after that" is a classic trap. It builds a strong outer wall around a building full of unlocked doors. The moment one connection lies about its identity — "route me the CEO's private messages" — the server complies, because the routing decision was based on an unverified, client-supplied name.
Normal usage never triggers this. You only find it by deliberately asking "what if the client lies?" — which is exactly the question a security review is supposed to ask.
The fix: verify a credential, not a claim
The fix follows a general principle worth remembering: stop trusting the claim, verify a credential.
The server was updated to check a real, signed proof of identity during the connection handshake, and derive identity from that — never from text the client supplied. Connections without a valid credential still connect, but they only receive public information, the same as a logged-out visitor would. Identity became something the server computes, not something the client asserts.
Here's the difference in flow:
sequenceDiagram
participant Browser as Browser Layer
participant Bg as Background Layer (Desktop)
participant Server
Note over Browser,Server: Browser connection
Browser->>Server: Open connection (cookie auto-attached)
Server->>Server: Verify signed credential from cookie
Server-->>Browser: Authenticated — full notifications
Note over Bg,Server: Desktop background connection
Bg->>Server: Open connection (no cookie, no credential)
Server->>Server: No valid credential found
Server-->>Bg: Treated as anonymous — public info only
The twist that makes this a good story
Fixing the server broke the desktop app — silently.
The desktop app's real-time connection lives in that cookie-less background layer, which had no built-in way to present a credential. The web version kept working fine, because cookies rode along automatically. The desktop version quietly stopped receiving notifications — no error, no crash. The server just saw an anonymous connection and, correctly, sent it nothing personal.
That's the lesson: a security fix on one side of a system can create a functional regression on the other side, especially across a browser / non-browser boundary — and the failure can be completely silent, which makes it the hardest kind to catch.
Takeaways
- Never derive identity from client-supplied data. A name in a connection query is a hint, not a credential.
- "Already authenticated to connect" is not "authorized for everything after." Authenticate and authorize each sensitive action separately.
- Desktop-wrapped web apps have two transport layers with different defaults. Cookies auto-attach in the browser layer and don't in the background layer — plan credentials for both.
- Cross-layer security changes need cross-layer testing. The web team's fix was correct and necessary — but it silently disabled notifications for every desktop user, because nobody tested the non-browser transport path against the new rule.
- Silent failures are the dangerous ones. No error was thrown; a feature just stopped. Add a test that specifically asserts the desktop connection is recognized, not just connected.