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 theSet-Cookieheader on the WebSocket101 Switching Protocolsresponse.
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 1006repeatedly. No frames are exchanged. - Server-side, your
openhandler fires, then the connection closes withReceived 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-Cookieon the upgrade response path. The svelte-realtime client emits a one-shotconsole.warnpointing at this page when it sees the open/close fingerprint at runtime.
Fix
Three pieces:
- Export an
authenticatehook fromsrc/hooks.ws.{js,ts}. It runs as a normal HTTPPOST /__ws/authbefore every upgrade (including reconnects), so cookies you set ride a204 No Contentresponse that proxies route correctly. - Opt into the client preflight with
configure({ auth: true }). - 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, 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.
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.
| Approach | Cookie travels on | Cloudflare Tunnel |
|---|---|---|
Set-Cookie from upgrade() (via upgradeResponse()) | 101 Switching Protocols | Dropped - edge tears down the connection |
Set-Cookie from authenticate() (POST /__ws/auth) | 204 No Content | Routed correctly |
Related docs
- hooks.ws.js - authenticate hook
- Adapter - Refreshing session cookies on WebSocket connect
- Client APIs - Connection hooks (
configure({ auth })) - Errors - Cloudflare-Tunnel “open then close 1006” detector
Was this page helpful?