WebSocket Authentication — Realtime

WebSocket Authentication WebSocket tidak punya built-in auth. Kamu harus authenticate saat connection, bukan per-message. Token-based Auth // Client: kirim…

Autentikasi WebSocket artinya memastikan siapa yang ada di ujung koneksi sebelum server mengirim data apa pun kepadanya. Otorisasinya: memutuskan room mana yang boleh ia ikuti. Bayangin pintu belakang Laundry Bu Rini khusus staf. Mas Eko menunjukkan kartu staf sekali di pintu (handshake), lalu berada di dalam seharian (koneksi yang terus terbuka). Penjaga juga melihat ia datang dari arah mana (Origin), bisa memberi karcis masuk sekali pakai yang dibeli di loket (tiket), dan meminta kartunya lagi saat jam kerja habis (periksa ulang).

🧪 Tebakan cepat: Di browser, bisakah kamu menambahkan header Authorization: Bearer ... ke koneksi WebSocket, misalnya new WebSocket(url, { headers: { Authorization: 'Bearer abc' } })? Jawabannya ada di bagian "Browser gak bisa menambah header".

Browser gak bisa menambah header

API WebSocket di browser cuma menerima dua hal: URL dan daftar subprotocol, new WebSocket(url, protocols). Gak ada tempat untuk header. Argumen kedua dianggap subprotocol, jadi objek { headers } diubah jadi teks [object Object] dan ditolak. Dicoba:

Browser Hasil new WebSocket(url, { headers: {...} })
Chromium 147 SyntaxError: Failed to construct 'WebSocket': The subprotocol '[object Object]' is invalid.
Firefox 146 SyntaxError: An invalid or illegal string was specified
WebKit 26.4 SyntaxError: The string did not match the expected pattern.

Itu jawaban tebakannya: gak bisa. Yang dikirim browser sendiri di handshake: header Origin (halaman asalnya) dan cookie untuk host itu. Cookie gak membedakan port: dicoba di ketiga browser, halaman http://localhost:8000 mengirim cookie localhost ke ws://localhost:3000. Jadi pilihannya: cookie session (dengan cek Origin), tiket singkat, payload auth Socket.IO, atau token di URL (hindari).

Cara 1: cookie session, dan cek Origin

Bu Rini sudah login di http://localhost:8000, jadi cookie session Laravel ikut terkirim ke server realtime. Server realtime bertanya ke Laravel siapa pemilik cookie itu.

Bahayanya Cross-Site WebSocket Hijacking: halaman di situs lain membuka WebSocket ke server realtime-mu, dan browser ikut membawa cookie Bu Rini. CORS gak berlaku untuk WebSocket. Dicoba dengan socket.io 4.8.4 yang memasang cors: { origin: ['http://localhost:8000'] }: halaman dari situs lain dengan transports: ['websocket'] tetap tersambung, dan di Firefox 146 tersambung sebagai Bu Rini (cookie tanpa atribut SameSite ikut terkirim). Chromium dan WebKit gak mengirim cookie itu ke situs lain, tapi cookie SameSite=None dikirim Chromium dan Firefox.

const BOLEH = ['http://localhost:8000']
const io = new Server(3000, {
  cors: { origin: BOLEH, credentials: true },                            // cuma untuk polling HTTP
  allowRequest: (req, cb) => cb(null, BOLEH.includes(req.headers.origin)), // juga untuk WebSocket
})

Cara 2: tiket singkat dari endpoint HTTP

Halaman meminta tiket ke Laravel lewat request biasa (cookie session ikut, dan fetch dari halaman yang sama lolos pemeriksaan CSRF Laravel 13 lewat header Sec-Fetch-Site). Tiketnya berlaku 60 detik dan cuma sekali pakai.

// routes/web.php
Route::post('/api/realtime/ticket', function (Request $request) {
    $isi = $request->user()->username.'.'.(time() + 60).'.'.bin2hex(random_bytes(4));

    return ['ticket' => $isi.'.'.hash_hmac('sha256', $isi, config('services.realtime.secret'))];
})->middleware('auth');
// server realtime (Node), rahasia yang sama di REALTIME_SECRET
import { createHmac, timingSafeEqual } from 'node:crypto'
const terpakai = new Set()

function cekTiket(tiket) {
  const [user, exp, nonce, tanda] = String(tiket).split('.')
  const harus = createHmac('sha256', process.env.REALTIME_SECRET).update(`${user}.${exp}.${nonce}`).digest('hex')
  if (!tanda || tanda.length !== harus.length || !timingSafeEqual(Buffer.from(tanda), Buffer.from(harus))) return null
  if (Number(exp) * 1000 < Date.now()) return null // lewat 60 detik
  if (terpakai.has(nonce)) return null             // sudah pernah dipakai
  terpakai.add(nonce)
  return user
}

Cara 3: payload auth Socket.IO, dicek di middleware

// browser: fungsi auth dipanggil di setiap percobaan sambung, jadi tiket selalu baru
const staf = io('http://localhost:3000/staff', {
  auth: (cb) =>
    fetch('/api/realtime/ticket', { method: 'POST', headers: { Accept: 'application/json' } })
      .then((r) => r.json())
      .then(({ ticket }) => cb({ ticket })),
})

// server
io.of('/staff').use((socket, next) => {
  const user = cekTiket(socket.handshake.auth.ticket)
  if (!user) return next(new Error('tiket tidak sah'))
  socket.data.user = user // dipakai handler lain, bukan peran kiriman client
  next()
})

Kenapa jangan token di query string

io(url, { query: { token } }) atau new WebSocket('ws://localhost:3000/papan?token=...') menaruh token di URL. Dicoba, server menerima GET /socket.io/?token=eyJhbGciOiJIUzI1NiJ9.rahasia&EIO=4&transport=websocket&sid=.... URL lengkap seperti ini tercatat di access log Nginx, proxy, dan alat monitoring, dan dibaca siapa pun yang membaca log. Kalau terpaksa lewat URL (WebSocket biasa tanpa Socket.IO), taruh tiket 60 detik sekali pakai, bukan token login.

Koneksi panjang: periksa ulang

Koneksi WebSocket bisa terbuka berjam-jam. Session yang sah saat handshake bisa kedaluwarsa (bawaan Laravel 120 menit) atau dicabut, misalnya Mas Eko logout atau berhenti bekerja. Pemeriksaan sekali di awal gak cukup.

staf.on('connection', (socket) => {
  const batas = setTimeout(() => socket.disconnect(true), 2 * 60 * 60 * 1000) // tiket baru tiap 2 jam
  socket.on('disconnect', () => clearTimeout(batas))
  socket.join(`user:${socket.data.user}`)
})
// saat Laravel mengabarkan logout Mas Eko:
staf.in('user:eko').disconnectSockets(true)

// browser
staf.on('disconnect', (alasan) => {
  if (alasan === 'io server disconnect') staf.connect() // tiket baru diambil oleh fungsi auth
})

Dicoba: diputus server, client menerima alasan io server disconnect, socket.active jadi false, dan gak menyambung lagi sendiri. staf.connect() memanggil fungsi auth lagi, jadi Laravel yang memutuskan: session masih sah, tiket baru keluar; sudah logout, 401 (berkat Accept: application/json, bukan redirect ke halaman login).

Setiap join room diperiksa

Autentikasi menjawab "siapa", otorisasi menjawab "boleh apa". Pak Darto cuma boleh mengikuti order miliknya, order:12:

io.on('connection', (socket) => {
  socket.on('order:ikuti', async (orderId, ack) => {
    const order = await db.order(orderId)
    if (!order || order.pelanggan !== socket.data.user) return ack({ ok: false, error: 'bukan pesananmu' })
    socket.join(`order:${orderId}`)
    ack({ ok: true })
  })
})

order:ikuti 12 dari HP Pak Darto: { ok: true }. order:ikuti 13 (milik Mbak Wulan): { ok: false, error: 'bukan pesananmu' }, dan ia gak pernah masuk room itu.

⚠️ Jebakan yang sering ketemu

TL;DR: Browser gak bisa menambah header ke handshake WebSocket (new WebSocket(url, protocols) saja; objek { headers } melempar SyntaxError), tapi mengirim Origin dan cookie (yang gak membedakan port). Cookie session butuh cek Origin di allowRequest, karena CORS gak berlaku untuk WebSocket (Cross-Site WebSocket Hijacking). Tiket 60 detik sekali pakai dari POST /api/realtime/ticket, ditandatangani HMAC, dikirim lewat payload auth Socket.IO (paket pertama, bukan URL) dan dicek di middleware. Token di query string tercatat di log. Koneksi persistent, jadi periksa ulang: putuskan saat kedaluwarsa atau logout, lalu sambung lagi dengan tiket baru. Setiap join room diperiksa: Pak Darto cuma boleh order:12.

Yang akan kamu pelajari