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:


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 };
  }
};

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.

WhatWhereSame cookies?
Page load+page.server.js load()Yes
Form action+page.server.js actionsYes
API route+server.jsYes
Server hookhooks.server.js handle()Yes
WebSocket upgradehooks.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
}
PropertyTypeDescription
headersobjectAll request headers, lowercased keys
cookiesobjectParsed cookies from the Cookie header
urlstringThe request path + query string
remoteAddressstringClient 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.).

HookPurposeDeny 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, responds 204 No Content with any Set-Cookie headers from cookies.set() (recommended).
  • false - responds 401 Unauthorized. The client does not open the WebSocket.
  • A full Response - used as-is; any cookies.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 with adapter({ websocket: { authPath: '/api/ws-auth' } }).
  • The hook is only mounted when authenticate is exported from hooks.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:

MethodDescription
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?