The token boundary
A Chatfuel token can read and change every bot in an account, so it never leaves the server — and everything about the proxy follows from that.
A Chatfuel token can read and change every bot in an account. There is no read-only token and no per-bot token, so a token in a browser is an account in a browser.
It therefore never gets there. Every call the app makes goes to its own origin under
/chatfuel/*, and a proxy running on the server side attaches the token. The client in the browser
is constructed without one — there is no code path that could send it, because it has nothing to
send.
One proxy, three hosts
The proxy is one source tree with three ways to run it, which is why the gate, the fences and the error codes are identical wherever you deploy.
| Where | What runs it |
|---|---|
| Development | a Vite plugin, inside the dev server |
| Your own server | server/entry.ts, a Node HTTP server that also serves the built client |
| Vercel | api/chatfuel.ts, a serverless function behind a rewrite |
All three mount the same routes: a GraphQL POST, a WebSocket on the same path, and a REST
passthrough for file uploads.
What the proxy strips, and what it adds
The outgoing request is built from scratch rather than copied and amended. That is the whole
mechanism: the browser's Authorization header and its cookies are not stripped by a filter that
someone could forget to update — they are never in the object that goes upstream at all. The only
thing added is the token.
Nothing prints it, either. The master token is never logged, and the one outbound-proxy string that does reach a log line has its password replaced first.
A missing token is a configuration problem, not a crash: the proxy answers with a synthetic
ProxyTokenMissing envelope rather than taking the dev server down with it.
The WebSocket is relayed, not forwarded
Subscriptions cannot be a tunnel, because a tunnel would carry whatever the browser sent as its
credentials. So the relay reads frames. It accepts the subprotocol, waits for the browser's
connection_init, decides whether that connection may proceed, and only then opens the upstream
socket and sends its own connection_init carrying the token. Every other frame passes through
verbatim in both directions.
The consequence worth knowing: an unauthenticated socket never touches Chatfuel at all. When the upstream socket does drop, the browser's socket is closed too and the client reconnects through a fresh relay.
Anything named VITE_* is compiled into the browser bundle at build time. Anything else is read
by the proxy at runtime. A secret with a VITE_ prefix is not a secret — which is why the token,
the Supabase service-role key and the admin password do not have one. The full list of variables
and which side reads each is in environment
variables.
What it costs
Two things.
The hop is real: every call is your origin, then Chatfuel. On Vercel that hop is a function with a duration limit, which is what bounds how long a subscription's socket lives — see Vercel.
And the proxy is code in your project, not a service we run. The whole tree is copied in under
vendor/chatfuel-proxy/, and your app declares ws, undici and https-proxy-agent as runtime
dependencies because of it. If you delete it, you have nothing to talk to Chatfuel with.
With the auth module installed the same proxy takes on a second job — deciding whose request this
is, not only where it goes. That is the auth gate.