Wintersalmon | Blog

Authentication is not authorization: three security stories from one quarter

5 min read

The games are multiplayer, so identity and trust are features, not chores — and one quarter delivered three reminders that authentication is never authorization. A planned Google sign-in, an unplanned npm supply-chain advisory, and a latent WebSocket subscribe bug all converged on the same rule: authorize at every resource join.

  • Story 1 — planned. POST /api/v1/auth/google ships as ID-token flow; email collisions require explicit consent, never silent merge.
  • Story 2 — unplanned. The March 31 axios compromise (GHSA-fw8c-xr5c-95f9) is audited across 65 package.json files in an afternoon — zero exposure.
  • Story 3 — latent. apps/game-ws-relay-service/src/ws-handler.ts accepted any roomId from any authenticated user; six tests now guard room:subscribe.
  • The principle. A valid JWT, a valid maintainer account, and a valid email match are all facts, not permissions.

Story 1: ID-token over auth-code, and never silent-merge

The motivation was friction — every game friend who bounced off the register form was a friend lost. POST /api/v1/auth/google accepts a Google ID token (not an authorization code), verifies it server-side via google-auth-library@10.6.2, and issues our own session cookies.

const ticket = await client.verifyIdToken({
  idToken,
  audience: process.env.GOOGLE_OAUTH_CLIENT_ID,
});
const { sub, email, name, picture } = ticket.getPayload();

ID-token flow won over authorization-code because we only need identity (sub, email, name, picture), not Google API access — no callback URL, no PKCE, no server redirect. A new collection google_oauth_credentials (keyed by googleId, joined to users.userId, both fields uniquely indexed) keeps users schema-stable; Google-only users carry passwordHash: null.

The decision worth recording is the email-collision rule. If a Google account’s email matches an existing email/password user, the endpoint returns an explicit error and requires consent before linking. Silent merge is a footgun — anyone who registers victim@gmail.com with email/password could have a stranger’s Google sign-in inherit their session history. Identity proof is not ownership proof.

Story 2: four greps, zero exposure, deferred deliberately

On March 31, 2026, axios@1.14.1 and axios@0.30.4 shipped to npm with a postinstall RAT dropper smuggled through plain-crypto-js@4.2.1 — live for roughly three hours before unpublish. cloudnest has 65 package.json files plus a Bun lockfile, so the audit had to be mechanical:

  1. Direct deps — grep "axios" across every package.json.
  2. Transitive lockfile — grep ^axios plus a substring sweep in bun.lock.
  3. Source imports — grep from "axios" | require("axios") | import("axios") across .ts/.tsx/.js/.jsx/.mjs/.cjs.
  4. IOC scan — rg -n 'plain-crypto-js' . bun.lock.

Result: zero exposure. The only axios substring hit was gaxios@7.1.4 — Google’s HTTP client, pulled in transitively by the same google-auth-library@10.6.2 from Story 1. Different package, not affected.

The audit incidentally surfaced 26 unrelated bun audit findings (5 high in vite, undici, devalue, @sveltejs/adapter-node). I deliberately did not fix them on the audit branch — the audit captures methodology, remediation is per-CVE. Mixing them produces a branch where neither is finished, so the audit doc merged alone and the highs went to a follow-up issue.

Story 3: a Tuesday question that should have been asked months earlier

April 12, reading apps/game-ws-relay-service/src/ws-handler.ts for an unrelated reason. I asked Claude one flat question — “what verifies that this user can subscribe to this room?” — and the answer was: nothing. The room:subscribe handler accepted any roomId from any authenticated user. A guest could open the WebSocket, send { type: "room:subscribe", roomId: "<any>" }, and silently receive every move event in real time.

const room = await findRoom(roomId);
if (!room) return reply({ type: "error", message: "Room not found" });
const isHost = room.hostUserId === userId;
const isParticipant = room.participantUserIds?.includes(userId) ?? false;
if (!isHost && !isParticipant) {
  return reply({ type: "error", message: "Not authorized" });
}

findRoom is wired via the existing DatabaseClient singleton through createWSHandler in apps/game-ws-relay-service/src/server.ts. Six tests cover the matrix: non-participant rejected, host allowed, participant allowed, missing room rejected, malformed roomId rejected without DB hit, legacy room (undefined participantUserIds) where host still wins. The room-join flow had always validated participation correctly — the hole was specifically the long-lived subscribe path, where authentication had been silently treated as authorization.

Authorize at every join

Story The fact The missing authorization
Google OAuth A valid Google ID token proves identity Email match does not prove ownership of an existing account
axios compromise A valid maintainer account proves publisher A valid release is not implied — trust is per-release
WS subscribe A valid JWT proves connection identity Per-room access is a separate decision per subscribe

The future-me checklist is one line: when adding a new long-lived connection or a new identity link, write the per-resource authorization check before the connection logic, not after. Three different incidents, one rule. I would rather not learn it a fourth time.

#security #authorization

AI workflow note

Claude played a different role in each story. For Story 1, the security-reviewer agent ran against the OAuth design doc before code was written — the email-collision rule came out of that review. For Story 2, a single prompt (“audit cloudnest’s exposure to GHSA-fw8c-xr5c-95f9 across 65 package.json files; 4-point methodology — direct deps, lockfile, source imports, filesystem IOC”) produced the audit document verbatim and saved roughly three hours of hand-grepping. Story 3 was the most useful and the most accidental: Claude was reading ws-handler.ts with me on an unrelated task when I asked the authorization question, and “what verifies that this user can do this?” is now a standing prompt for any code that touches a long-lived connection or an identity link.


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.