WebSocket 인증 심층 분석 — 토큰, 상태 유지 연결, 그리고 아무도 경고하지 않는 CORS 우회

발행: (2026년 6월 9일 PM 03:13 GMT+9)
10 분 소요
원문: Dev.to

Source: Dev.to

WebSocket 인증 심층 분석 — 토큰, 상태 유지 연결, 그리고 아무도 경고하지 않는 CORS 우회

WebSocket은 강력합니다. 클라이언트와 서버 간에 실시간 양방향 통신을 가능하게 하여 채팅 앱, 실시간 대시보드, 협업 도구 등에 최적입니다. 하지만 인증에 관해서는 일반 HTTP 요청과는 상당히 다르게 동작하며, 이 차이점 때문에 주의하지 않으면 미묘한 보안 취약점이 생길 수 있습니다.

이번 포스트에서는 다음 내용을 살펴보겠습니다:

  • HTTP 요청을 인증하는 세 가지 주요 방법
  • 왜 그 중 일부는 WebSocket에 전혀 적용되지 않는가
  • WebSocket 인증에 적합한 접근법
  • 대부분의 튜토리얼이 완전히 넘어가는 교묘한 CORS 우회

예시에서는 백엔드 예제로 ws 라이브러리를 사용할 것입니다. socket.io를 사용한다면 추상화 레이어가 일부를 처리해 주지만, 핵심 개념과 취약점은 동일합니다.


일반 HTTP 요청은 어떻게 인증하나요?

WebSocket으로 넘어가기 전에, 일반 HTTP 요청에서 인증 토큰을 전달하는 세 가지 표준 방법을 다시 정리해 보겠습니다.

1. 쿠키

쿠키는 가장 안전한 옵션 중 하나입니다. HttpOnly 쿠키에 JWT 토큰을 저장하면 페이지의 JavaScript가 접근할 수 없으므로 XSS 공격에 대한 강력한 방어가 됩니다.

트레이드오프: 쿠키는 캐시 친화적이지 않습니다. Cloudflare 같은 CDN은 보통 URL을 기준으로 응답을 캐시하고 쿠키 내용은 무시합니다. 따라서 일부 아키텍처에서는 캐시 문제가 발생할 수 있습니다.

2. URL 파라미터 (Path & Query)

토큰을 URL 자체에 실어 보낼 수 있습니다 — 경로 파라미터 혹은 쿼리 파라미터 형태로.

# Path parameter
GET https://api.example.com/data/user123

# Query parameter
GET https://api.example.com/data?token=abc123&filter=active

Path 파라미터는 보통 리소스 식별 및 콘텐츠 구분에 사용됩니다. 쿼리 파라미터는 더 유연해 여러 조건 필드를 담을 수 있습니다.

두 경우 모두 URL에 포함되므로 브라우저 히스토리, 서버 로그, CDN 캐시 등에 남아 민감한 토큰을 전달하기엔 이상적이지 않습니다.

3. Authorization 헤더

API 인증에 가장 널리 쓰이는 방법입니다. 요청에 헤더를 직접 붙입니다:

GET /api/resource HTTP/1.1
Host: yourserver.com
Authorization: Bearer <token>

깨끗하고 URL에 포함되지 않으며, Clerk, Auth0, Firebase Auth, 커스텀 JWT 설정 등 거의 모든 인증 서비스와 프레임워크가 지원합니다.


WebSocket 연결은 어떻게 동작하나요?

WebSocket 연결은 처음부터 시작되는 것이 아니라 기존 HTTP 연결을 업그레이드합니다. 흐름은 다음과 같습니다:

  • 클라이언트(브라우저)가 Upgrade: websocket 헤더가 포함된 특수 HTTP 요청을 보냅니다.
  • 서버가 이를 인식하고 허용한다면 101 Switching Protocols 상태 코드로 응답합니다.

TCP 연결은 그대로 유지되며, 이제 클라이언트와 서버는 자유롭고 효율적으로 메시지를 주고받을 수 있습니다.

Client                             Server
  |                                   |
  |  GET /ws HTTP/1.1                 |
  |  Upgrade: websocket               |
  |  Connection: Upgrade              |
  |  Sec-WebSocket-Key: xyz...        |
  |---------------------------------> |
  |                                   |
  |  HTTP/1.1 101 Switching Protocols |
  |  Upgrade: websocket               |
  |  Connection: Upgrade              |
  |                                   |

WebSocket의 핵심 특성 중 하나는 상태 유지(stateful) 라는 점입니다. HTTP와 달리 서버가 응답을 보낸 순간 클라이언트를 잊어버리는 것이 아니라, WebSocket 연결이 살아있는 동안 클라이언트를 기억합니다. 이는 인증에 큰 영향을 미칩니다.


WebSocket 인증 문제

여기서 어려움이 발생합니다. 브라우저의 기본 WebSocket API—프론트엔드에서 연결을 시작할 때 사용하는 API—는 매우 제한적입니다:

WebSocket 핸드쉐이크 요청에 커스텀 헤더나 본문을 추가할 수 없습니다.

이는 브라우저 수준의 제한입니다. new WebSocket(url)을 호출하면 브라우저가 업그레이드 요청을 자동으로 구성·전송하고, 커스텀 헤더를 삽입할 방법을 제공하지 않습니다.

따라서 Authorization 헤더 방식은 바로 사용할 수 없습니다.

// ❌ 브라우저에서는 동작하지 않음
const ws = new WebSocket('wss://yourserver.com/ws', {
  headers: {
    Authorization: 'Bearer your_token_here' // 브라우저가 무시함
  }
});

그렇다면 우리는 어디에 의존해야 할까요? 쿠키 혹은 URL 파라미터가 남습니다. 두 방법 모두 동작합니다—업그레이드 요청은 여전히 HTTP 요청이므로 쿠키는 자동으로 전송됩니다. 하지만 간편함과 유연성을 고려했을 때(특히 Clerk 같은 서비스에서 JWT를 사용할 경우), 쿼리 파라미터에 토큰을 넣는 방식이 가장 실용적이며 널리 채택된 접근법입니다.


해결책: 토큰을 쿼리 파라미터로 전달하기

프론트엔드

// 인증 제공자 혹은 로컬 스토리지에서 토큰을 가져옴
const token = localStorage.getItem('auth_token');

// WebSocket URL에 쿼리 파라미터로 토큰을 삽입
const ws = new WebSocket(`wss://yourserver.com/ws?token=${token}`);

ws.onopen = () => {
  console.log('WebSocket 연결이 수립되었습니다');
};

ws.onmessage = (event) => {
  console.log('수신된 메시지:', event.data);
};

ws.onerror = (error) => {
  console.error('WebSocket 오류:', error);
};

백엔드 (ws 라이브러리 사용)

서버에서는 connection 이벤트가 발생할 때 URL에서 토큰을 추출하고 검증한 뒤, 해당 사용자 정보를 소켓 인스턴스에 연결합니다.

const WebSocket = require('ws');
const jwt = require('jsonwebtoken');
const url = require('url');

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws, req) => {
  // 1️⃣ 쿼리 파라미터에서 토큰 추출
  const params = new URLSearchParams(url.parse(req.url).query);
  const token = params.get('token');

  if (!token) {
    ws.close(4001, 'Unauthorized: No token provided');
    return;
  }

  // 2️⃣ 토큰 검증
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);

    // 3️⃣ 검증된 사용자 정보를 소켓에 연결
    ws.user = decoded;
    console.log(`User ${decoded.userId} connected`);
  } catch (err) {
    ws.close(4001, 'Unauthorized: Invalid or expired token');
    return;
  }

  // 4️⃣ 메시지 처리 — 재인증 필요 없음
  ws.on('message', (message) => {
    console.log(`Message from ${ws.user.userId}:`, message.toString());
    // ws.user는 이미 검증되어 여기서 바로 사용 가능
  });

  ws.on('close', () => {
    console.log(`User ${ws.user?.userId} disconnected`);
  });
});

인증은 한 번만 수행됩니다 — 설계상 의도된 동작

WebSocket은 상태 유지이기 때문에 인증은 한 번, 핸드쉐이크 시점에만 이루어집니다. 서버는 토큰을 검증하고, 사용자 정보를 소켓 객체에 붙인 뒤, 이후 전송되는 모든 메시지에 대해 그 사용자를 기억합니다. 따라서 각 메시지마다 재인증할 필요가 없습니다.

Client                             Server
  |                                   |
  |  WS Handshake + ?token=abc123     |
  |  ✅ 토큰 검증 완료                |
  |  ws.user = { id: 42, role: 'user'}|
  |   |  ← 재인증 불필요
  |  "message: How are you?" ------>  |  ← ws.user 여전히 연결됨
  |  "message: Update this" ------->  |  ← ws.user 여전히 연결됨

이 동작은 상태 유지라는 특성 덕분에 가능한데, 이는 WebSocket 인증 설계의 핵심 포인트입니다.

(이하 내용은 원문이 끊긴 부분이므로 여기까지 번역합니다.)

0 조회
Back to Blog

관련 글

더 보기 »

Eidentic 소개

Today we're releasing Eidentic, an open-source TypeScript SDK for building AI agents with self-improving memory and the production fundamentals built in — not b...

Typescript의 타입

Introdução Tipos são uma forma de definir a “forma” ou o contrato dos dados que estamos usando no código. Pensando em Javascript puro, ele é dinâmico: você pode...