인증은 인가가 아니다: 한 분기에 겪은 세 가지 보안 이야기
게임이 멀티플레이어이기에 신원과 신뢰는 잡일이 아니라 기능입니다. 그리고 한 분기 동안 인증은 결코 인가가 아니다라는 사실을 세 번이나 상기시켜 주는 일이 있었습니다. 계획된 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.1과 axios@0.30.4가 plain-crypto-js@4.2.1을 통해 밀반입된 postinstall RAT 드로퍼와 함께 npm에 올라갔습니다 — unpublish되기까지 약 3시간이 살아 있었습니다. cloudnest는 65개의 package.json에 Bun 락파일까지 있었기에, 감사는 기계적으로 진행해야 했습니다:
- 직접 의존성 — 모든
package.json에서"axios"를 grep. - 트랜시티브 락파일 —
bun.lock에서^axiosgrep과 부분 문자열 스윕. - 소스 임포트 —
.ts/.tsx/.js/.jsx/.mjs/.cjs에서from "axios" | require("axios") | import("axios")를 grep. - 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" });
}
findRoom은 apps/game-ws-relay-service/src/server.ts의 createWSHandler를 통해 기존 DatabaseClient 싱글턴에 연결됩니다. 6개의 테스트가 매트릭스를 커버합니다: 비참여자 거부, 호스트 허용, 참여자 허용, 존재하지 않는 방 거부, 비정상 roomId는 DB 조회 없이 거부, 레거시 방(participantUserIds가 undefined)에서 호스트는 여전히 통과. 방 입장(join) 플로우는 늘 참여 자격을 올바르게 검증해 왔지만, 구멍은 인증을 인가로 묵시적으로 취급해 버린 장기 연결의 subscribe 경로에 있었습니다.
모든 join 지점에서 인가하라
| Story | 사실 | 빠져 있던 인가 |
|---|---|---|
| Google OAuth | 유효한 Google ID 토큰은 신원을 증명한다 | 이메일 일치는 기존 계정의 소유를 증명하지 않는다 |
| axios 사고 | 유효한 메인테이너 계정은 publisher를 증명한다 | 유효한 릴리스를 의미하지는 않는다 — 신뢰는 릴리스 단위 |
| WS subscribe | 유효한 JWT는 연결의 신원을 증명한다 | 방 단위 접근은 subscribe마다 별도의 결정이다 |
미래의 나에게 남기는 체크리스트는 한 줄입니다: 새로운 장기 연결이나 새로운 신원 연결을 추가할 때, 연결 로직보다 먼저 리소스 단위 인가 검사를 작성하라. 사건은 셋, 규칙은 하나. 네 번째로 다시 배우고 싶지는 않습니다.
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를 읽다가 인가 질문을 던졌고, “이 사용자가 이걸 할 수 있는지 무엇이 검증하느냐?”는 이제 장기 연결이나 신원 연결을 다루는 코드를 만질 때마다 던지는 표준 프롬프트가 되었습니다.
