53.1 웹소켓은 로그인을 어떻게 확인해요?

보통 웹에서는 로그인 확인이 쉬워요. 브라우저가 요청마다 쿠키(cookie, 로그인 표식)나 Authorization 헤더(권한 정보를 담는 칸)를 자동으로 실어 보내니까요. 그런데 웹소켓은 사정이 좀 달라요.


브라우저의 웹소켓 API는 연결을 열 때 커스텀 헤더를 못 붙여요. 그러니까 우리가 늘 쓰던 Authorization: Bearer 토큰 방식을 여기선 그대로 못 쓴다는 거예요. 이걸 모르고 "헤더에 토큰 실으면 되겠지" 했다가 벽을 만나는 분이 많아요. 저도 그랬고요.


게다가 웹소켓은 한번 연결되면 계속 열려 있어요. 일반 요청은 그때그때 검사하면 되지만, 웹소켓은 연결을 맺는 그 순간에 "넌 누구냐"를 확실히 물어야 하죠. 안 그러면 로그인도 안 한 사람이 소켓을 붙잡고 앉아 있게 돼요. 그럼 토큰을 어디에 실어 보내야 할까요?

53.2 토큰을 어디에 실어 보내요?

현실에서 쓰는 방법은 크게 셋이에요. 하나씩 볼게요.


1) 첫 메시지로 보내기. 연결은 일단 열되, 인증 전까지는 아무것도 안 해주는 방식이에요. 클라이언트가 붙자마자 토큰을 첫 메시지로 보내고, 서버는 그걸 확인해야만 방 입장 같은 걸 열어줘요.


상황: 연결 직후 토큰을 검사하고, 통과 전엔 막아요.
ws.authed = false;
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (!ws.authed) {
const user = verifyToken(msg.token); // 서명 검증
if (!user) return ws.close(4001, 'auth failed');
ws.user = user;
ws.authed = true;
return;
}
// 여기부터는 인증된 사람만 도달해요
handle(ws, msg);
});


ws.authed 깃발이 false인 동안엔 토큰 검사 말고는 아무것도 안 해요. verifyToken이 실패하면 바로 끊고요. 통과해야 handle로 넘어가죠. 가장 깔끔하고 많이 쓰는 방식이에요.


2) 쿼리스트링에 실기. wss://.../socket?token=abc처럼 주소 뒤에 붙이는 거예요. 서버는 악수 단계에서 이걸 읽어요. 간단하지만 토큰이 주소에 그대로 노출돼요. 서버 접속 로그나 중간 장비에 주소가 찍히면 토큰이 새죠. 그래서 쓸 거면 짧게 살고 마는 임시 토큰으로 써야 안전해요.


3) 쿠키에 기대기. 웹소켓 악수도 결국 HTTP로 시작하니, 같은 출처라면 브라우저가 쿠키를 자동으로 실어 보내요. 헤더를 못 붙이는 문제를 우회하는 셈이죠. 다만 쿠키를 쓰면 다음 절에서 말할 출처 위조 공격에 특히 조심해야 해요.

53.3 연결됐다고 아무거나 다 보내도 돼요?

여기서 초보들이 크게 데여요. "로그인 확인했으니 끝"이 아니에요. 인증(authentication, 너 누구냐)과 권한(authorization, 그걸 해도 되냐)은 다른 문제거든요. 철수가 로그인한 진짜 철수인 건 맞아요. 그런데 철수가 영희의 비밀방에 들어가겠다고 하면요?


연결 한 번 인증했다고 그다음 메시지를 무조건 믿으면 안 돼요. 클라이언트가 보내는 값은 얼마든지 조작할 수 있으니까요. 그래서 메시지마다 "이 사람이 이걸 할 권한이 있나"를 서버가 다시 따져야 해요.


상황: 방에 들어올 때 그 방을 볼 권한이 있는지 확인해요.
function handle(ws, msg) {
if (msg.type === 'join_room') {
// 클라가 보낸 roomId를 믿지 말고, 서버가 직접 확인
if (!canAccessRoom(ws.user.id, msg.roomId))
return ws.send(JSON.stringify({ type: 'error', reason: 'forbidden' }));
join(ws, msg.roomId);
}
}


canAccessRoom이 이 절의 주인공이에요. 클라이언트가 보낸 roomId를 그대로 믿지 않고, 이 사용자가 저 방의 멤버인지를 서버가 스스로 확인하죠. 이게 없으면 남의 방 번호만 알아내도 아무나 들어가 남의 대화를 엿봐요. 채팅 유출 사고는 대부분 이 메시지별 권한 검사를 빼먹어서 나요.

53.4 ws랑 wss는 뭐가 달라요?

주소를 보면 ws://로 시작하는 것과 wss://로 시작하는 게 있어요. 딱 http와 https 차이예요. ws암호화가 안 된 맨몸 통신이고, wssTLS(전송 암호화)로 감싼 통신이에요.


ws로 주고받으면 같은 와이파이에 앉은 누군가가 그 내용을 그대로 훔쳐볼 수 있어요. 로그인 토큰도, 채팅 내용도 평문으로 흘러가니까요. 카페 공용 와이파이를 떠올리면 아찔하죠. 그래서 실서비스는 무조건 wss예요. 예외는 없어요.


다행히 어렵지 않아요. 이미 https를 쓰는 도메인이라면 웹소켓 주소만 wss://로 맞추면 돼요. 클라이언트에서 지금 페이지가 https인지 보고 자동으로 골라주면 실수도 줄고요.


상황: 페이지가 https면 자동으로 wss를 골라요.
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
const socket = new WebSocket(scheme + '://' + location.host + '/ws');


이렇게 해두면 개발 중 로컬에선 ws, 배포된 https 사이트에선 wss로 알아서 붙어요. 배포하고 나서 "어 왜 안 붙지" 하는 흔한 실수 하나를 미리 막아주죠.

53.5 아무 사이트나 내 소켓에 붙어도 돼요?

마지막이 제일 놓치기 쉬워요. 웹소켓에는 일반 요청 같은 같은 출처 규칙(same-origin, 다른 사이트의 접근 제한)이 자동으로 걸리지 않아요. 무슨 말이냐면, 악성 사이트에 사용자가 들어가 있어도 그 페이지가 당신 서버의 웹소켓에 슬쩍 연결할 수 있다는 거예요.


더 위험한 건 앞서 쿠키 인증을 썼을 때예요. 브라우저는 웹소켓 악수에도 쿠키를 자동으로 실어 보내니, 악성 사이트가 연 연결에도 피해자의 로그인 쿠키가 딸려가요. 그럼 그 사이트가 피해자 행세로 소켓을 쓰죠. 이걸 출처 위조 공격(Cross-Site WebSocket Hijacking)이라고 해요.


막는 법은 서버가 악수 때 오는 Origin 헤더(요청이 어느 사이트에서 왔는지)를 직접 확인하는 거예요. 브라우저는 이 값을 위조하지 못하니 믿을 만한 판단 기준이 돼요.


상황: 허용한 출처가 아니면 악수 자체를 거절해요.
const allowed = ['https://myapp.com'];
wss.on('connection', (ws, req) => {
if (!allowed.includes(req.headers.origin)) {
ws.close(4003, 'bad origin');
return;
}
// 여기부터 정상 처리
});


내 사이트 주소만 allowed에 넣고, 그 외의 Origin에서 온 연결은 악수 단계에서 바로 끊어요. 쿠키 인증을 쓴다면 이 출처 검사는 선택이 아니라 필수예요. 토큰 방식이어도 넣어두면 한 겹 더 안전하고요.

53.6 오늘 배운 걸 정리해볼게요

실시간에서 인증과 보안의 핵심만 다시 짚을게요. 실시간은 연결이 오래 열려 있어서 한 번 뚫리면 오래 뚫려요. 그만큼 첫 단추가 중요해요.


웹소켓은 헤더에 토큰을 못 붙이니 첫 메시지·쿼리스트링·쿠키 중 하나로 토큰을 넘겨 연결 순간에 신원을 확인해요. 인증했다고 끝이 아니라 메시지마다 권한을 다시 따져 남의 방을 못 열게 막고요. 통신은 훔쳐보기를 막으려 무조건 wss로, 그리고 아무 사이트나 내 소켓에 못 붙게 Origin 검사로 출처 위조를 걷어내요.


한 문장으로 줄이면 "연결은 누구나 열 수 있다고 가정하라"예요. 그 가정 위에서 신원·권한·암호화·출처를 겹겹이 쌓는 거죠. 직접 확인하고 싶으면 개발자 도구로 다른 탭에서 당신 서버 소켓에 붙어 보세요. Origin 검사를 빼면 붙고, 넣으면 악수에서 튕겨 나가는 걸 보면 왜 필요한지 확 와닿아요.