Quay lại bài viết
24 thg 8, 2026
9 min read

WebSocket: Kết Nối Được Thiết Lập Như Thế Nào, Và Làm Sao Để Bảo Mật Nó?

Bạn đang xây một tính năng chat realtime. Chọn WebSocket, code chạy ngon, message bay qua bay lại mượt mà. Đến ngày security review, một câu hỏi đơn giản làm cả team khựng lại: “Làm sao server biết connection này là của user nào? Và điều gì ngăn một trang web lạ mở connection tới server của mình?”

Lúc này bạn mới nhận ra: mọi cơ chế auth quen thuộc của HTTP — Authorization header, middleware check token trên từng request — đều không áp dụng thẳng được cho WebSocket. Lý do nằm ngay ở cách connection được thiết lập.

Bài này sẽ đi qua hai phần: WebSocket handshake diễn ra như thế nào, và từ đó, các lớp bảo vệ thực tế cho một WebSocket server.


1. Kết nối WebSocket được thiết lập như thế nào?

WebSocket là một giao thức cho phép client và server duy trì một kết nối hai chiều (full-duplex — cả hai phía đều có thể chủ động gửi dữ liệu bất kỳ lúc nào) trên một TCP connection duy nhất, thay vì mô hình request-response một chiều của HTTP.

Điều thú vị là WebSocket không tự mở một kết nối kiểu riêng. Nó bắt đầu bằng một HTTP request bình thường, gọi là handshake:

GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: https://example.com Cookie: session=abc123

Ba điểm đáng chú ý:

  • Upgrade: websocket báo cho server rằng client muốn chuyển giao thức từ HTTP sang WebSocket trên chính TCP connection này.
  • Sec-WebSocket-Key là một chuỗi base64 ngẫu nhiên. Server nối chuỗi này với một GUID cố định được định nghĩa trong chuẩn WebSocket (RFC 6455), hash bằng SHA-1, rồi trả kết quả về trong header Sec-WebSocket-Accept. Cặp key này không phải cơ chế bảo mật — nó chỉ chứng minh server thực sự hiểu giao thức WebSocket, tránh trường hợp một proxy hoặc server HTTP thường trả về response cache nhầm.
  • Vì handshake là HTTP, nó mang theo mọi thứ một HTTP request có: URL (kèm query string), header, và cookie. Chi tiết này chính là nền tảng cho phần authentication ở dưới.

Nếu đồng ý, server trả về:

HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Status code 101 Switching Protocols nghĩa là: từ thời điểm này, TCP connection giữ nguyên nhưng hai bên ngừng nói chuyện bằng HTTP. Dữ liệu chuyển sang dạng các frame nhị phân của WebSocket, và không còn khái niệm request hay response nữa — chỉ còn hai bên gửi message cho nhau.


2. Vì sao secure WebSocket khó hơn HTTP?

Cách thiết lập kết nối ở trên dẫn đến ba hệ quả khiến bài toán bảo mật khác hẳn HTTP:

  • Không set được custom header từ browser. WebSocket API của browser (new WebSocket(url)) chỉ nhận URL và subprotocol, không có chỗ truyền header. Nghĩa là pattern quen thuộc Authorization: Bearer <token> không dùng được.
  • Auth chỉ diễn ra được một lần, tại handshake. Sau khi upgrade, không còn request nào để middleware chặn lại kiểm tra. Server phải quyết định “connection này của ai, có được phép không” ngay lúc bắt tay, rồi gắn danh tính đó vào connection.
  • Handshake không bị Same-Origin Policy chặn. Same-Origin Policy là cơ chế của browser ngăn JavaScript trên trang A đọc dữ liệu từ trang B khác origin, và CORS là cách server nới lỏng có kiểm soát cơ chế đó. Nhưng WebSocket handshake nằm ngoài cả hai: JavaScript trên bất kỳ trang web nào cũng có thể gọi new WebSocket('wss://your-server.com') và browser sẽ gửi request đi, kèm cả cookie của user nếu có.

Hệ quả thứ ba nguy hiểm nhất, và có tên riêng: Cross-Site WebSocket Hijacking (CSWSH) — user đang đăng nhập app của bạn, lỡ mở một trang độc hại, trang đó âm thầm mở WebSocket tới server của bạn kèm session cookie của user, và nghiễm nhiên chat, đọc dữ liệu dưới danh nghĩa nạn nhân.

Từ đây, ta xây các lớp bảo vệ.


3. Lớp nền: luôn dùng wss

wss:// là phiên bản WebSocket chạy trên TLS (Transport Layer Security — giao thức mã hóa đường truyền, chính là chữ S trong HTTPS), tương tự quan hệ giữa https://http://. Dùng wss:// mang lại hai thứ:

  • Toàn bộ handshake và message được mã hóa, chống nghe lén và sửa đổi trên đường truyền. Điều này đặc biệt quan trọng vì ticket, cookie đều đi qua handshake.
  • Kết nối ổn định hơn trong thực tế: nhiều proxy và thiết bị mạng trung gian không hiểu WebSocket sẽ cắt hoặc phá kết nối ws:// thuần, trong khi traffic wss:// được bọc trong TLS trên port 443 nên đi qua trơn tru.

Đây là lớp bắt buộc, không phải lựa chọn. Mọi thứ phía dưới đều giả định bạn đã dùng wss://.


4. Chống CSWSH: validate Origin header

Browser luôn tự động gắn header Origin (origin của trang web đang chạy đoạn JavaScript mở kết nối) vào WebSocket handshake, và JavaScript không thể giả mạo header này. Vì vậy, lớp phòng thủ đầu tiên chống CSWSH rất đơn giản: server chỉ chấp nhận handshake có Origin nằm trong danh sách cho phép.

import { WebSocketServer } from 'ws' const ALLOWED_ORIGINS = new Set(['https://example.com', 'https://app.example.com']) const wss = new WebSocketServer({ port: 8080, verifyClient: ({ origin }) => ALLOWED_ORIGINS.has(origin), })

Lưu ý giới hạn: Origin chỉ đáng tin khi request đến từ browser. Một client tự viết (script, curl, app native) có thể đặt Origin tùy ý. Vì vậy Origin validation chỉ chống được tấn công cross-site qua browser — nó không thay thế được authentication, mà bổ sung cho nó.


5. Authentication: hai cách thực dụng

5.1. Ticket qua query params

Vì không gắn được Authorization header, một pattern phổ biến là ticket-based authentication: dùng kênh HTTP sẵn có (nơi auth hoạt động bình thường) để xin một tấm vé, rồi trình vé đó khi mở WebSocket.

Luồng hoạt động gồm ba bước:

  1. Client gọi một HTTP endpoint đã được bảo vệ sẵn (ví dụ POST /ws-ticket) để xin ticket. Server tạo một chuỗi ngẫu nhiên, lưu kèm user ID với thời gian sống ngắn (khoảng 30 giây), rồi trả về cho client.
  2. Client mở kết nối kèm ticket trên query string: wss://example.com/chat?ticket=abc...
  3. Server validate ticket tại handshake, xóa ticket ngay sau lần dùng đầu tiên, và gắn user ID vào connection.
import { randomBytes } from 'crypto' const tickets = new Map<string, { userId: string; expiresAt: number }>() function issueTicket(userId: string) { const ticket = randomBytes(32).toString('hex') tickets.set(ticket, { userId, expiresAt: Date.now() + 30_000 }) return ticket } function redeemTicket(ticket: string) { const entry = tickets.get(ticket) tickets.delete(ticket) if (!entry || entry.expiresAt < Date.now()) return null return entry.userId }

Vì sao phải là ticket ngắn hạn dùng một lần, mà không phải nhét thẳng JWT hay session token vào query string? Vì query string bị ghi lại ở khắp nơi: access log của server, log của proxy và load balancer, browser history. Một token dài hạn nằm trong log là một credential bị lộ. Còn một ticket sống 30 giây và đã bị vô hiệu sau lần dùng đầu tiên thì kẻ đọc được log cũng không làm gì được.

Trong production, Map in-memory nên được thay bằng Redis với TTL để hoạt động đúng khi server có nhiều instance.

Cách thứ hai tận dụng chính đặc điểm “handshake là HTTP”: browser tự động gửi cookie kèm handshake. Nếu app của bạn đã dùng session cookie cho phần HTTP, WebSocket server có thể đọc cookie đó tại handshake và xác định user — không cần thêm bước xin ticket nào.

Nhưng nhớ lại mục 2: chính việc browser tự gửi cookie là thứ làm CSWSH khả thi. Đây là lúc thuộc tính SameSite phát huy tác dụng. SameSite là một thuộc tính của cookie quy định browser có được gửi cookie đó kèm theo request xuất phát từ một site khác hay không:

  • SameSite=Strict — cookie chỉ được gửi khi request xuất phát từ chính site đó.
  • SameSite=Lax — như Strict, nhưng nới lỏng cho việc điều hướng thông thường (user bấm link sang site của bạn). Request WebSocket do trang khác mở vẫn bị chặn cookie.
  • SameSite=None — gửi cookie cho mọi cross-site request, tức là từ bỏ lớp bảo vệ này.

Với SameSite=Lax hoặc Strict, kịch bản CSWSH sụp đổ: trang độc hại vẫn mở được kết nối tới server của bạn, nhưng browser từ chối đính kèm session cookie, nên handshake đến nơi trong tình trạng vô danh và bị server từ chối.

res.setHeader('Set-Cookie', 'session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/')

Hai thuộc tính đi kèm cũng quan trọng không kém: HttpOnly ngăn JavaScript đọc cookie (giảm thiệt hại khi bị XSS — kẻ tấn công chèn được script vào trang của bạn), và Secure đảm bảo cookie chỉ được gửi qua kết nối mã hóa.

Một lưu ý: đừng vì đã có SameSite mà bỏ Origin validation ở mục 4. Hai lớp này bảo vệ theo hai cách khác nhau, và bảo mật tốt luôn xếp chồng nhiều lớp (defense in depth) — lớp này thủng thì còn lớp kia.


6. Hardening sau handshake

Handshake xong không có nghĩa là xong chuyện bảo mật. Vài việc nên làm cho phần đời còn lại của connection:

  • Authorize theo từng message. Đã kết nối không có nghĩa là được làm mọi thứ. Mỗi message yêu cầu hành động (join room, xóa dữ liệu) vẫn phải được kiểm tra quyền dựa trên user ID đã gắn lúc handshake.
  • Giới hạn kích thước message. Thư viện ws có option maxPayload — một message vài trăm MB có thể đánh gục server nếu không chặn.
  • Giới hạn số connection và tần suất message cho mỗi user. WebSocket connection giữ tài nguyên lâu dài, nên một user mở hàng nghìn connection hoặc bắn message liên tục là một dạng tấn công từ chối dịch vụ giá rẻ.
  • Ping pong định kỳ. Giao thức WebSocket có sẵn cặp frame ping và pong để kiểm tra đầu bên kia còn sống. Server nên ping định kỳ và đóng những connection không phản hồi, tránh tích tụ connection chết chiếm tài nguyên.

7. Kết luận

Quay lại câu hỏi ở đầu bài: server biết connection của ai bằng cách nào, và điều gì ngăn trang web lạ kết nối? Câu trả lời gói gọn trong mấy ý:

  • WebSocket bắt đầu bằng một HTTP request có header Upgrade; sau response 101 Switching Protocols, TCP connection giữ nguyên và chuyển sang truyền frame hai chiều.
  • Vì browser không cho set custom header và không còn request nào sau khi upgrade, mọi việc xác thực phải diễn ra tại handshake.
  • Luôn dùng wss:// — vừa mã hóa credential trong handshake, vừa giúp kết nối sống sót qua các proxy trung gian.
  • Validate header Origin để chặn Cross-Site WebSocket Hijacking từ browser.
  • Xác thực bằng ticket ngắn hạn dùng một lần qua query params khi WebSocket server tách riêng, hoặc session cookie với SameSite khi cùng domain — và dù chọn cách nào cũng giữ cả Origin validation làm lớp thứ hai.
  • Sau handshake: authorize từng message, giới hạn payload, giới hạn connection, và ping pong để dọn connection chết.

Liên quan