Cloudflare-Tunnel cookie fix

You are here because the svelte-realtime runtime warned you with https://svti.me/cf-cookies, or your README link landed on this page. The warning fires when the client sees two consecutive WebSocket open then close cycles inside one second with no traffic. That fingerprint almost always means an edge proxy is silently dropping the Set-Cookie header on the WebSocket 101 Switching Protocols response.

Symptom

  • WebSockets work locally and on a bare server. They break the moment you put Cloudflare Tunnel (or any other strict edge proxy) in front.
  • The browser shows WebSocket connection ... close 1006 repeatedly. No frames are exchanged.
  • Server-side, your open handler fires, then the connection closes with Received TCP FIN before WebSocket close frame.
  • A live stream that used to update never receives a single message; reconnect loops indefinitely.

Diagnosis

The WebSocket upgrade is an HTTP request. When your upgrade() hook attaches a Set-Cookie to the upgrade response (for example to rotate a session cookie on connect), that header rides on a 101 Switching Protocols response. The HTTP/2 WebSocket bridging that Cloudflare’s edge performs rewrites this 101 response - and Set-Cookie on a 101 trips the edge into tearing the connection down. The behavior is undocumented, but reproducible on every Cloudflare Tunnel and proxy connector.

The result: the connection is established, the cookie is dropped, the connection closes immediately, the reconnect attempt has no fresh cookie, and you get an infinite loop.

svelte-adapter-uws emits a build-time warning when it detects a Set-Cookie on the upgrade response path. The svelte-realtime client emits a one-shot console.warn pointing at this page when it sees the open/close fingerprint at runtime.

Fix

Three pieces:

  1. Export an authenticate hook from src/hooks.ws.{js,ts}. It runs as a normal HTTP POST /__ws/auth before every upgrade (including reconnects), so cookies you set ride a 204 No Content response that proxies route correctly.
  2. Opt into the client preflight with configure({ auth: true }).
  3. Use svelte-adapter-uws >= 0.4.12.

1. src/hooks.ws.js

// src/hooks.ws.js
export { message, close, unsubscribe } from 'svelte-realtime/server';

export function upgrade({ cookies }) {
  const session = validateSession(cookies.session_id);
  return session ? { id: session.userId, name: session.name } : false;
}

export function authenticate({ cookies }) {
  const session = validateSession(cookies.get('session_id'));
  if (!session) return false;

  if (shouldRotate(session)) {
    cookies.set('session_id', rotate(session), {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      path: '/'
    });
  }
  return { id: session.userId, name: session.name };
}

The authenticate event exposes the SvelteKit event shape: { 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.

2. Client opt-in

<!-- src/routes/+layout.svelte -->
<script>
  import { configure } from 'svelte-realtime/client';
  configure({ auth: true });
</script>

With auth: true the client runs fetch('/__ws/auth', { method: 'POST', credentials: 'include' }) before every WebSocket connect, including after automatic reconnects. The browser then attaches the freshly-rotated cookie to the upgrade request that follows.

The client coalesces concurrent connects into a single in-flight preflight, treats 4xx as terminal (the user is not authenticated), and falls back to normal reconnect backoff on 5xx and network errors.

To target a custom path (for example behind a Cloudflare Access rule), pass a string:

configure({ auth: '/api/ws-auth' });

3. Adapter version

npm install svelte-adapter-uws@^0.4.12

The hook is only mounted when authenticate is exported from hooks.ws - no runtime cost when unused. The default auth path is /__ws/auth. Override with adapter({ websocket: { authPath: '/api/ws-auth' } }) if you need to.


Why this works

The authenticate hook runs as a standard HTTP POST. Set-Cookie rides on a 204 No Content response, which Cloudflare and every other proxy handle correctly. The browser stores the refreshed cookie. The WebSocket upgrade that follows immediately afterwards now carries the fresh cookie in its Cookie header - no Set-Cookie on the 101 response is ever needed.

ApproachCookie travels onCloudflare Tunnel
Set-Cookie from upgrade() (via upgradeResponse())101 Switching ProtocolsDropped - edge tears down the connection
Set-Cookie from authenticate() (POST /__ws/auth)204 No ContentRouted correctly

Was this page helpful?