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, misalnyanew 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
})
allowRequestmemeriksa headerOriginsetiap koneksi. Dicoba: halaman situs lain langsungconnect_error(websocket error), halaman laundry tetap tersambung.- Cookie session Laravel 13 bawaannya
SameSite=LaxdanHttpOnly. Itu menahan situs lain, tapi bukan port atau subdomain lain di situs yang sama. Origin tetap harus dicek.
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
}
- Tiket
rini.1791686520.9f2c41d0.<tanda>dibuat pukul 09.41.00 dan berlaku sampai 09.42.00. Dicoba:hash_hmacdi PHP dancreateHmacdi Node menghasilkan tanda yang sama,2ea1c4d8.... - Dipakai pukul 09.41.01: masuk sebagai
rini. Dipakai lagi: ditolak (sudah terpakai). Tiket serupa yang belum terpakai tapi baru dipakai pukul 09.42.05: ditolak (kedaluwarsa). - Dengan beberapa proses Node, simpan nonce di Redis:
SET tiket:9f2c41d0 1 NX EX 60.
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()
})
authdikirim di paket pertama,40/staff,{"ticket":"..."}, bukan di URL. Ini bentuk "token saat handshake atau di pesan pertama".- Dicoba: koneksi yang ditolak middleware memicu
connect_errordengan pesantiket tidak sah,socket.activejadifalse, dan client gak mencoba lagi sendiri.
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
- Mengandalkan
corsSocket.IO untuk menahan situs lain. Itu cuma untuk polling. CekOrigindiallowRequest. - Token login di query string. Tercatat di log. Pakai
auth, atau tiket 60 detik sekali pakai. - Cuma memeriksa saat tersambung. Koneksi berjam-jam melewati kedaluwarsa dan logout. Putuskan dan minta tiket baru.
- Percaya nama room atau peran kiriman client. Periksa setiap
order:ikutiterhadap pemilik order di database.
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.