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, a REST
passthrough for file uploads, and — where the deployment configures them — the sign-up, publishing
and admin routes. The proxy page is the table.
They also all answer /chatfuel/healthz, and the body is {"ok":true} or {"ok":false} and
nothing else. The route is open to whoever can reach the deployment, so it says healthy or not and
stops: which mode this deployment is in is the deployer's business, and the startup log tells them.
It sits under the prefix rather than at the root because at the root it is not the proxy's to
claim — on Vercel a bare /healthz matches the SPA rewrite and answers 200 with index.html,
which is a liveness check that is green because it never reached the proxy at all.
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; a configuration problem is announced
by the name of the variable and never by its value; and the one outbound-proxy string that does
reach a log line has its credentials replaced first — username included, because a corporate proxy
is routinely configured as HTTPS_PROXY=http://<token>@proxy:8080, where the whole credential is
the username.
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.
Every response the proxy writes carries the same set of security headers, on all three hosts and on every path — proxied answers, the health answer, static files and their 404s. The list is on the proxy page.
Who may reach it at all
The token stays on the server, and that is only half the question. The other half is who gets to spend it.
Two checks stand in front of every route, and the WebSocket upgrade asks both again, because the
upgrade listener is bolted to the bare HTTP server where no middleware runs. Origin asks which
page is calling: the proxy answers on the app's own origin and forwards under the master token, so
a request carrying the browser's ambient credentials is a request it would forward, which is what a
cross-site request forgery is. Host asks the question Origin cannot — am I the name that
page thinks it reached? A browser writes Origin and Host from the same address, so a name the
attacker owns, pointed at this server, makes the two agree honestly. That is DNS rebinding, and
against a proxy holding the master token it gets a check of its own. The host is asked first: a
request that reached the wrong name has no business collecting a CORS header on its way out. The
codes are on errors; the two variables are on environment
variables.
Two configurations the proxy will not serve at all, and it says so before it binds:
- Open mode on a host that is not loopback. With no gate, every caller that reaches the server drives Chatfuel under the deployment's master token — no identity is asked for, so none is checked, and none is in the log either.
- Every origin allowed. A
*origin list answers any page on the internet with credentials, so any page may script this proxy out of a visitor's browser.
The Node server prints the refusal and never binds the socket; the dev server throws before the
middleware mounts; the serverless function, which has no socket to withhold, boots, answers the
health route so the deployment can be diagnosed, and answers 503 ProxyRefusedToServe to
everything else. Deploying has the three ways out.
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 later frame is re-serialised
from what the relay read rather than passed along as it arrived.
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 src/ 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.
Half a boundary, without the gate
With the auth module installed the same proxy takes on a second job — deciding whose request this
is, not only where it goes. A request must carry a Supabase session whose user belongs to this
tenant, and a half-configured deployment refuses everything rather than letting anything through.
That is the auth gate, and what a caller past it may then do is
ceilings and fences.
Without it the boundary is only half of one. The token stays on the server, and the proxy has no callers to tell apart, so it forwards whatever arrives: anyone who can reach the URL acts as the deployment's own Chatfuel account, within its bot fence. That is fine for a tool on a private network and wrong on a public one, which is why the refusal above exists.
/admin is outside the gate either way. It has one of its own — a password, a signed cookie and a
wrong-password counter — and what it opens is the whole account behind the token, so a signed-in
user who may open nothing in the app can still try that password. Unset, the routes are not
mounted at all; set, the value is the only thing in front of them.
Which makes protecting that door the operator's job, and nothing in the template does it for you: there is no second factor, no IP allowlist, and no lockout that outlives a process unless the deployment gives the proxy a database to keep the count in. Running the panel on a public host is a decision — the admin panel is the material to make it with.