WebSocket 완전 정복 · 2부. 중급: 프로토콜 내부와 설계 패턴 · 16편 중 10편
이전 편: 9편 — 재연결 전략
9편까지가 끊긴 연결을 되살리는 쪽이었다면, 이번 편은 연결을 누구에게 열어 줄지 정하는 쪽입니다. 인증을 어느 단계에 두는지, CORS가 없는 WebSocket에서 Origin 검사가 왜 빠질 수 없는지, 그리고 연결이 성립한 뒤에도 권한 확인이 계속돼야 하는 이유를 봅니다.
인증 방식 네 가지
| 방식 | 설명 | 주의 |
|---|---|---|
| 쿠키 | 같은 도메인이면 자동 전송 | CSWSH 방어(Origin 검사) 필수 |
| 쿼리스트링 토큰 | ?token=... | 액세스 로그에 남음. 짧은 수명 일회용만 |
| 티켓 방식 (권장) | HTTPS API로 30초 일회용 티켓 발급 → 그 티켓으로 접속 → 사용 즉시 폐기 | 로그에 남아도 이미 무효 |
| 첫 메시지 인증 | 연결 후 첫 메시지로 토큰 전송 | 미인증 연결이 자원 점유 → 수 초 타임아웃 필요 |
인증은 연결 수락 전(Upgrade 단계)에 하는 것이 가장 좋습니다.
Upgrade 단계에서 인증하기
import http from "node:http";
import { WebSocketServer } from "ws";
const server = http.createServer();
const wss = new WebSocketServer({ noServer: true });
const ALLOWED_ORIGINS = new Set(["https://app.example.com"]);
server.on("upgrade", async (req, socket, head) => {
// 1) Origin 검사 (CSWSH 방어)
if (!ALLOWED_ORIGINS.has(req.headers.origin ?? "")) {
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
return socket.destroy();
}
// 2) 티켓 검증
const url = new URL(req.url!, "http://localhost");
const user = await consumeTicket(url.searchParams.get("ticket"));
if (!user) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
return socket.destroy();
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req, user);
});
});
server.listen(8080);
declare function consumeTicket(t: string | null): Promise<{ id: string } | null>;CSWSH — WebSocket에는 CORS가 없다
WebSocket에는 CORS가 적용되지 않습니다. 악성 사이트가 new WebSocket("wss://your-app.com/ws")를 열면 브라우저가 사용자 쿠키를 실어 보냅니다. 쿠키만으로 인증하면 공격자가 사용자 권한으로 통신할 수 있습니다. Origin 허용 목록 검사로 방어합니다.
인가 — 연결 시 인증으로 끝이 아니다
연결 시 인증으로 끝이 아닙니다. 채널 구독·메시지마다 권한을 확인하고, 장기 연결은 토큰 만료에 맞춰 재검증하거나 4000번대 코드로 끊습니다.
더 깊이
- 풀스택 개발자를 위한 네트워크 8장 — 브라우저 보안 모델: SOP, CORS, 쿠키: 이 편이 전제로 깔고 지나간 것들의 출처입니다. Origin이 무엇으로 정해지는지(8.2), 일반 HTTP 요청에서는 CORS가 어떻게 막아 주는지(8.3), 쿠키가 자동으로 실리는 조건과
SameSite(8.4), CSRF(8.5)를 봐 두면 CSWSH가 그 방어막이 없는 자리에서 같은 구조로 일어나는 공격이라는 것이 보입니다. - 4편 — 브라우저 API: 브라우저 API는 임의의 헤더를 설정할 수 없다는 제약이 이 편의 출발점입니다. 쿠키·쿼리스트링·티켓·첫 메시지 네 가지는 전부
Authorization헤더를 붙일 수 없어서 생긴 우회로입니다.