Skip to content
ChatfuelSDK
Concepts

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.

WhereWhat runs it
Developmenta Vite plugin, inside the dev server
Your own serverserver/entry.ts, a Node HTTP server that also serves the built client
Vercelapi/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.

On this page