Authentication
WebSocket authentication uses the exact same cookies as your SvelteKit app. When the browser opens a WebSocket connection, it sends all cookies for the domain - including session cookies set by SvelteKit’s cookies.set(). No tokens, no query parameters, no extra client-side code.
Here’s the full flow from login to authenticated WebSocket:
Step 1: Login sets a cookie
Standard SvelteKit form action. Nothing WebSocket-specific here.
src/routes/login/+page.server.js
import { authenticate, createSession } from '$lib/server/auth.js';
export const actions = {
default: async ({ request, cookies }) => {
const form = await request.formData();
const email = form.get('email');
const password = form.get('password');
const user = await authenticate(email, password);
if (!user) return { error: 'Invalid credentials' };
const sessionId = await createSession(user.id);
// This cookie is automatically sent on WebSocket upgrade requests
cookies.set('session', sessionId, {
path: '/',
httpOnly: true,
sameSite: 'strict',
secure: true,
maxAge: 60 * 60 * 24 * 7 // 1 week
});
return { success: true };
}
}; Step 2: WebSocket handler reads the same cookie
The upgrade() function in hooks.ws.js receives parsed cookies from the upgrade request. Return an object to accept the connection (it becomes ws.getUserData()), or return false to reject with 401.
src/hooks.ws.js
import { getSession } from '$lib/server/auth.js';
export async function upgrade({ cookies }) {
// Same cookie that SvelteKit set during login
const sessionId = cookies.session;
if (!sessionId) return false; // -> 401, connection rejected
const user = await getSession(sessionId);
if (!user) return false; // -> 401, expired or invalid session
// Attach user data to the socket - available via ws.getUserData()
return { userId: user.id, name: user.name, role: user.role };
}
export function open(ws, { platform }) {
const { userId, role } = ws.getUserData();
console.log(`${userId} connected (${role})`);
// Subscribe to user-specific and role-based topics
ws.subscribe(`user:${userId}`);
if (role === 'admin') ws.subscribe('admin');
}
export function close(ws, { platform }) {
const { userId } = ws.getUserData();
console.log(`${userId} disconnected`);
} Step 3: Client - nothing special needed
The browser sends cookies automatically on the upgrade request. If the session is invalid, the connection is rejected and auto-reconnect will retry (useful if the user logs in later).
src/routes/dashboard/+page.svelte
<script>
import { on, status } from 'svelte-adapter-uws/client';
// The browser sends cookies automatically on the upgrade request.
// If the session is invalid, the connection is rejected and
// auto-reconnect will retry (useful if the user logs in later).
const notifications = on('notifications');
const userMessages = on('user-messages');
</script>
{#if $status === 'open'}
<span>Authenticated & connected</span>
{:else if $status === 'connecting'}
<span>Connecting...</span>
{:else}
<span>Disconnected (not logged in?)</span>
{/if} Step 4: Send messages to specific users
Because each user subscribes to their own user:{userId} topic in the open hook, you can send targeted messages from any server-side code via platform.publish().
src/routes/api/notify/+server.js
import { json } from '@sveltejs/kit';
export async function POST({ request, platform }) {
const { userId, message } = await request.json();
// Only that user receives this (they subscribed in open())
platform.publish(`user:${userId}`, 'notification', { message });
return json({ sent: true });
} Why this works
The WebSocket upgrade is an HTTP request. The browser treats it like any other request to your domain - it includes all cookies, follows the same-origin policy, and respects httpOnly/secure/sameSite flags. There’s no difference between how cookies reach a +page.server.js load function and how they reach the upgrade handler.
| What | Where | Same cookies? |
|---|---|---|
| Page load | +page.server.js load() | Yes |
| Form action | +page.server.js actions | Yes |
| API route | +server.js | Yes |
| Server hook | hooks.server.js handle() | Yes |
| WebSocket upgrade | hooks.ws.js upgrade() | Yes |
The upgrade() function
The upgrade function receives an UpgradeContext object:
{
headers: { 'cookie': '...', 'host': 'localhost:3000', ... }, // all lowercase
cookies: { session_id: 'abc123', theme: 'dark' }, // parsed from Cookie header
url: '/ws?token=abc', // request path + query string
remoteAddress: '127.0.0.1' // client IP
} | Property | Type | Description |
|---|---|---|
headers | object | All request headers, lowercased keys |
cookies | object | Parsed cookies from the Cookie header |
url | string | The request path + query string |
remoteAddress | string | Client IP address |
Return an object to accept the connection → it becomes ws.getUserData() in all subsequent hooks. Return false to reject with a 401 response. Omit the upgrade export entirely to accept all connections.
The subscribe hook
The subscribe hook fires when a client tries to subscribe to a topic. Use it for topic-level authorization - deciding which users can access which topics.
export function subscribe(ws, topic, { platform }) {
const { role } = ws.getUserData();
// Only admins can subscribe to admin topics
if (topic.startsWith('admin') && role !== 'admin') return false;
} Return false to deny the subscription. Omit the hook to allow all subscriptions.
This is separate from connection-level auth in upgrade(). A user might be allowed to connect (valid session) but not allowed to subscribe to certain topics (wrong role, not a member of a room, etc.).
| Hook | Purpose | Deny by returning |
|---|---|---|
upgrade() | Connection-level auth (is this user logged in?) | false → 401 |
subscribe() | Topic-level auth (can this user access this topic?) | false → subscription silently blocked |
Refreshing session cookies on WebSocket connect
For short-lived sessions you often want to rotate the session cookie every time a client connects. The obvious approach — attaching Set-Cookie to the 101 Switching Protocols response via upgradeResponse() — is RFC-compliant but silently rejected by Cloudflare Tunnel, Cloudflare’s proxy, and some other strict edge proxies. The symptom is that the WebSocket open handler fires server-side, then the connection closes with code 1006 (Received TCP FIN before WebSocket close frame) before any frames are exchanged. The adapter emits a build-time warning when it detects this pattern.
The adapter ships a first-class solution: the optional authenticate hook runs as a normal HTTP POST before the WebSocket upgrade. Set-Cookie rides on a standard 2xx response, which every proxy handles correctly; the browser then attaches the refreshed cookie to the upgrade request that follows.
Step 1: add an authenticate export to hooks.ws.js
// src/hooks.ws.js
import { getSession, renewSession } from '$lib/server/auth.js';
// Runs as POST /__ws/auth, before the WebSocket upgrade.
// cookies.set() becomes Set-Cookie on a standard 204 response.
export async function authenticate({ cookies }) {
const session = await getSession(cookies.get('session'));
if (!session) return false; // -> 401, client does not open the WebSocket
const renewed = await renewSession(session);
cookies.set('session', renewed.token, {
path: '/',
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7
});
}
// Your existing upgrade() hook stays unchanged - it reads the now-fresh cookie.
export async function upgrade({ cookies }) {
const session = await getSession(cookies.session);
if (!session) return false;
return { userId: session.userId, role: session.role };
} The authenticate event exposes the SvelteKit event shape you already know: { request, headers, cookies, url, remoteAddress, getClientAddress, platform }. Return values:
undefined/ nothing - success, responds204 No Contentwith anySet-Cookieheaders fromcookies.set()(recommended).false- responds401 Unauthorized. The client does not open the WebSocket.- A full
Response- used as-is; anycookies.set()calls are merged in.
Step 2: opt in from the client
import { connect } from 'svelte-adapter-uws/client';
// Hit /__ws/auth before every WebSocket connect (including reconnects)
connect({ auth: true });
// Or point at a custom path (e.g. behind a Cloudflare Access rule)
connect({ auth: '/api/ws-auth' }); With auth: true the client runs fetch('/__ws/auth', { method: 'POST', credentials: 'include' }) before every new WebSocket(...) call, including after automatic reconnects. Concurrent connect attempts share a single in-flight preflight. A 4xx response is treated as terminal (the user is not authenticated); 5xx and network errors fall back to the normal reconnect backoff.
Configuration
- The default auth path is
/__ws/auth. Override withadapter({ websocket: { authPath: '/api/ws-auth' } }). - The hook is only mounted when
authenticateis exported fromhooks.ws— no runtime cost when unused. - Dev mode (Vite plugin) mirrors the production route on the same path.
Why not put Set-Cookie on the 101?
Cloudflare’s HTTP/2 WebSocket bridging rewrites 101 responses, and Set-Cookie on the 101 trips the edge into tearing the connection down. This is undocumented Cloudflare behavior, but reproducible on every tunnel and proxy connector. The authenticate hook sidesteps it entirely by using a standard HTTP response.
If you are using svelte-realtime, see Cloudflare-Tunnel cookie fix for the corresponding configure({ auth: true }) client-side setup.
All hooks.ws hooks
The full set of hooks available in hooks.ws.js:
// Called during HTTP -> WebSocket upgrade.
// Return object to accept (becomes ws.getUserData()), false to reject.
export async function upgrade({ headers, cookies, url, remoteAddress }) { }
// Optional. Runs as POST /__ws/auth BEFORE every upgrade (including reconnects).
// Use to refresh session cookies behind proxies that drop Set-Cookie on 101.
// Return undefined for 204 success, false for 401, or a full Response.
export async function authenticate({ request, headers, cookies, url, remoteAddress, platform }) { }
// Called when a connection is established
export function open(ws, { platform }) { }
// Called when a message is received
// subscribe/unsubscribe messages from the client store are handled
// automatically BEFORE this function is called
export function message(ws, { data, isBinary }) { }
// Called when a client tries to subscribe to a topic
// Return false to deny
export function subscribe(ws, topic, { platform }) { }
// Called when a client unsubscribes from a topic
export function unsubscribe(ws, topic, { platform }) { }
// Called when the connection closes
export function close(ws, { code, message, platform }) { }
// Called when backpressure has drained (for flow control)
export function drain(ws, { platform }) { } The ws object is a uWebSockets.js WebSocket with these key methods:
| Method | Description |
|---|---|
ws.getUserData() | Returns whatever upgrade() returned |
ws.subscribe(topic) | Subscribe to a topic |
ws.unsubscribe(topic) | Unsubscribe from a topic |
ws.send(data) | Send a message to this connection |
ws.close() | Close the connection |
Was this page helpful?