Wintersalmon | Blog

인증은 인가가 아니다: 한 분기에 겪은 세 가지 보안 이야기

5 min read

게임이 멀티플레이어이기에 신원과 신뢰는 잡일이 아니라 기능입니다. 그리고 한 분기 동안 인증은 결코 인가가 아니다라는 사실을 세 번이나 상기시켜 주는 일이 있었습니다. 계획된 Google 로그인 도입, 예고 없이 터진 npm 공급망 권고, 그리고 잠복해 있던 WebSocket subscribe 버그 — 모두 같은 규칙으로 수렴했습니다: 모든 리소스 join 지점에서 인가하라.

  • Story 1 — 계획된 작업. POST /api/v1/auth/google은 ID-token 플로우로 출시되며, 이메일 충돌 시에는 묵시적 병합 없이 명시적 동의를 요구합니다.
  • Story 2 — 예기치 못한 사건. 3월 31일 axios 사고(GHSA-fw8c-xr5c-95f9)를 65개 package.json 전체에 대해 오후 한나절에 감사 — 노출 0.
  • Story 3 — 잠복해 있던 결함. apps/game-ws-relay-service/src/ws-handler.ts가 인증된 사용자라면 누구든 임의의 roomId를 받아들이고 있었고, 이제 6개의 테스트가 room:subscribe를 보호합니다.
  • 원칙. 유효한 JWT, 유효한 메인테이너 계정, 유효한 이메일 일치는 모두 사실일 뿐 권한이 아닙니다.

Story 1: authorization-code 대신 ID-token, 그리고 절대 묵시적 병합 없이

동기는 마찰이었습니다 — 회원가입 폼에서 이탈한 게임 친구 한 명은 잃은 친구 한 명과 같습니다. POST /api/v1/auth/google은 인가 코드(authorization code)가 아닌 Google ID 토큰을 받아 google-auth-library@10.6.2로 서버 측에서 검증한 뒤, 자체 세션 쿠키를 발급합니다.

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

ID-token 플로우가 authorization-code 플로우를 이긴 이유는 단순합니다 — 우리에게 필요한 것은 신원(sub, email, name, picture)뿐이며 Google API 접근이 아니기 때문에 callback URL도, PKCE도, 서버 리다이렉트도 필요 없습니다. 새 컬렉션 google_oauth_credentials(googleId로 키잉, users.userId에 조인, 두 필드 모두 유니크 인덱스)는 users 스키마를 안정적으로 유지해 주며, Google 전용 사용자는 passwordHash: null을 가집니다.

기록할 가치가 있는 결정은 이메일 충돌 규칙입니다. Google 계정의 이메일이 기존 이메일/비밀번호 사용자의 이메일과 일치하면, 엔드포인트는 명시적인 에러를 반환하고 연결 전에 동의를 요구합니다. 묵시적 병합은 발등 찍기입니다 — 누군가 victim@gmail.com으로 이메일/비밀번호 가입을 해 두었다면, 모르는 사람의 Google 로그인이 그 사람의 세션 이력을 그대로 물려받을 수 있기 때문입니다. 신원 증명은 소유 증명이 아닙니다.

Story 2: grep 네 번, 노출 0, 그리고 의도적 미루기

2026년 3월 31일, axios@1.14.1axios@0.30.4plain-crypto-js@4.2.1을 통해 밀반입된 postinstall RAT 드로퍼와 함께 npm에 올라갔습니다 — unpublish되기까지 약 3시간이 살아 있었습니다. cloudnest는 65개의 package.json에 Bun 락파일까지 있었기에, 감사는 기계적으로 진행해야 했습니다:

  1. 직접 의존성 — 모든 package.json에서 "axios"를 grep.
  2. 트랜시티브 락파일 — bun.lock에서 ^axios grep과 부분 문자열 스윕.
  3. 소스 임포트 — .ts/.tsx/.js/.jsx/.mjs/.cjs에서 from "axios" | require("axios") | import("axios")를 grep.
  4. IOC 스캔 — rg -n 'plain-crypto-js' . bun.lock.

결과: 노출 0. 유일하게 매칭된 axios 부분 문자열은 gaxios@7.1.4 — Story 1과 동일한 google-auth-library@10.6.2가 트랜시티브하게 끌어온 Google의 HTTP 클라이언트였습니다. 다른 패키지이며, 영향 없음.

감사 과정에서 부수적으로 26개의 무관한 bun audit 결과가 드러났습니다(vite, undici, devalue, @sveltejs/adapter-node에서 high 5건). 의도적으로 감사 브랜치에서는 고치지 않았습니다 — 감사는 방법론을 박제하는 것이고, 패치는 CVE 단위입니다. 둘을 섞으면 어느 쪽도 끝맺지 못한 브랜치가 되기에 감사 문서만 단독으로 머지하고, high 항목들은 후속 이슈로 빠졌습니다.

Story 3: 몇 달 전에 던졌어야 했던 화요일의 질문

4월 12일, 무관한 이유로 apps/game-ws-relay-service/src/ws-handler.ts를 읽고 있었습니다. Claude에게 단도직입적인 질문 하나를 던졌습니다 — “이 사용자가 이 방을 subscribe할 수 있는지 무엇이 검증하느냐?” — 답은 “아무것도”였습니다. room:subscribe 핸들러는 인증된 사용자라면 누구든 임의의 roomId를 받아들였습니다. 게스트가 WebSocket을 열고 { type: "room:subscribe", roomId: "<any>" }를 보내면, 모든 move 이벤트를 실시간으로 조용히 받아볼 수 있었습니다.

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" });
}

findRoomapps/game-ws-relay-service/src/server.tscreateWSHandler를 통해 기존 DatabaseClient 싱글턴에 연결됩니다. 6개의 테스트가 매트릭스를 커버합니다: 비참여자 거부, 호스트 허용, 참여자 허용, 존재하지 않는 방 거부, 비정상 roomId는 DB 조회 없이 거부, 레거시 방(participantUserIds가 undefined)에서 호스트는 여전히 통과. 방 입장(join) 플로우는 늘 참여 자격을 올바르게 검증해 왔지만, 구멍은 인증을 인가로 묵시적으로 취급해 버린 장기 연결의 subscribe 경로에 있었습니다.

모든 join 지점에서 인가하라

Story 사실 빠져 있던 인가
Google OAuth 유효한 Google ID 토큰은 신원을 증명한다 이메일 일치는 기존 계정의 소유를 증명하지 않는다
axios 사고 유효한 메인테이너 계정은 publisher를 증명한다 유효한 릴리스를 의미하지는 않는다 — 신뢰는 릴리스 단위
WS subscribe 유효한 JWT는 연결의 신원을 증명한다 방 단위 접근은 subscribe마다 별도의 결정이다

미래의 나에게 남기는 체크리스트는 한 줄입니다: 새로운 장기 연결이나 새로운 신원 연결을 추가할 때, 연결 로직보다 먼저 리소스 단위 인가 검사를 작성하라. 사건은 셋, 규칙은 하나. 네 번째로 다시 배우고 싶지는 않습니다.

#security #authorization

AI 워크플로우 메모

각 이야기에서 Claude의 역할은 달랐습니다. Story 1에서는 코드를 쓰기 전에 security-reviewer 에이전트를 OAuth 설계 문서에 돌렸고 — 이메일 충돌 규칙은 그 리뷰에서 나왔습니다. Story 2에서는 단일 프롬프트(“65개 package.json 전반에 걸쳐 GHSA-fw8c-xr5c-95f9에 대한 cloudnest의 노출을 감사하라; 4단계 방법론 — 직접 의존성, 락파일, 소스 임포트, 파일시스템 IOC”)가 감사 문서를 그대로 산출해 약 3시간의 수작업 grep을 절약해 주었습니다. Story 3은 가장 유용했고 동시에 가장 우연이었습니다: 무관한 작업으로 Claude와 함께 ws-handler.ts를 읽다가 인가 질문을 던졌고, “이 사용자가 이걸 할 수 있는지 무엇이 검증하느냐?”는 이제 장기 연결이나 신원 연결을 다루는 코드를 만질 때마다 던지는 표준 프롬프트가 되었습니다.


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.