The auth gate
Behind the proxy sits one Chatfuel account holding every customer's bot, so two fences decide what a request may name — and both fail closed.
Behind the proxy there is one Chatfuel account, and every bot the deployment serves lives in it. The token that reaches that account reaches all of them. So the question "may this request go through?" is not the same as "may this request name that bot?", and the proxy answers both — separately, and in that order.
Two fences, two questions
| Question | What answers it | What it asks |
|---|---|---|
| May this deployment touch that bot at all? | the workspace fence, always on | Chatfuel, with the master token |
| Does this signed-in person own that bot? | the auth gate, only with the auth module | your Supabase project, with the caller's own session |
They are not layers of the same check. The fence is a property of the deployment and applies with no
auth module installed at all; the gate is a property of the request. Where the gate applies, it
wins — its answer is narrower, because it is about one person rather than one account.
Without the auth module: the deployment fence
A Chatfuel account is a tree — workspaces, each holding bots — and the app lets a person move between the workspaces that account owns. So the fence is asked for rather than configured: the proxy queries the account's workspaces and their bots with the master token and caches the answer, defaulting to 60 seconds. A bot created in the Chatfuel dashboard five minutes ago belongs to the same account as the rest, and nobody has to redeploy for it to work.
The two failure modes are deliberately different. If a previous answer exists and a refresh fails,
that answer is served on for a short retry window — an upstream blip must not lock out an app whose
bots have not changed. If nothing is known yet and the query fails, the request is refused with
ProxyFenceUnavailable, because forwarding a bot id nobody has vouched for is the one thing worse
than an error.
With it: the session gate
Set both Supabase variables and every request must additionally carry the caller's session — a bearer
token on HTTP, an authToken in the WebSocket's connection_init — and every bot that request names
must belong to a workspace that session is a member of.
The proxy verifies nothing itself. It reads the JWT's unverified expiry, so an expired token is refused with zero network, and otherwise asks your own Supabase project which bots this caller may open. PostgREST checks the signature; a bad or expired token comes back as its 401.
The answers map onto codes a module can branch on. No session, or one Supabase rejects, is
AuthSessionRequired. A signed-in caller with no workspace yet that names a bot is
AuthTenantForbidden — a session-level state the app fixes by provisioning, not a permission
failure. A bot that belongs to somebody else is BotNotAllowed. Supabase unreachable is
ProxyAuthUnavailable, never a silent pass.
Both fail closed
Half a configuration is not a configuration. Set one of the two Supabase variables without the other
and every proxied request answers 500 ProxyAuthMisconfigured — the proxy refuses to guess which
half you meant, because both guesses are wrong in a way you would only find out about later. The
same instinct runs through the fence: unknown means refuse.
That is the behaviour to expect from a deployment that is misconfigured rather than broken. It looks like total failure, on purpose. Picking a host covers reading the gate's state off the startup line and the health route before anything else.
Answers are cached for 30 seconds, deliberately
The gate caches by sha256 of the token, for 30 seconds, bounded by the token's own expiry, and it
caches positive answers only. So a just-invited colleague is let in on their next request rather
than after a redeploy, and a refusal is never remembered. The raw token is never stored.
The window is a real one, and worth knowing rather than hiding: a member removed from a workspace can keep making HTTP calls for up to 30 seconds. WebSockets are stricter in one direction and looser in another — the socket is gated before it is opened, so an unauthenticated socket never reaches Chatfuel at all, but rotation is checked at connect only. Somebody removed mid-session keeps their open socket until it drops.
One tenant, many bots
The live model is one workspace per account with many Chatfuel bots inside it. Somebody signs up, the server creates their first bot with the deployment's master token, and they can add, rename and delete more. Colleagues arrive by invite into the same workspace, and which bots each of them may open is granted per person — owners and admins reach every bot in the workspace by role, and carry no grant rows at all.
An earlier version of the schema gave a workspace exactly one bot. That has not been true since the
0002_multi_bot migration, and any wording you find that still says "one tenant per deployed bot" is
stale.
Two constraints carry more weight than they look:
cf_tenants.created_byis unique. That single index is what makes sign-up safe — two tabs or a retried request cannot open two workspaces, and therefore cannot mint two accounts' worth of bots.- Adding a bot is two steps on purpose. The database decides whether the caller may, and reserves a row; then the server creates the bot in Chatfuel and attaches it with the service key. If either half fails the other is undone, so neither side is left holding what the other forgot.
Nothing caps how many bots an account may create. The ceiling is the Chatfuel workspace's own plan,
and a full one answers WorkspaceFull — to sign-ups and to "new bot" alike.
What the fence does not cover
Be precise about what this buys, because the gap is not obvious.
The fence is exact for anything that names a bot — the variable, the root field, on HTTP, REST and every WebSocket subscribe frame. Naming somebody else's bot never reaches Chatfuel.
It does not cover operations that name a flow, a contact or a conversation instead. Those ids are issued per bot and never listed across bots, but the proxy does not resolve them to a bot and Chatfuel will not either — behind the master token every bot belongs to the same account. Treat resource ids as unguessable, not as a boundary.
And a request that names no bot at all passes every fence. That is why, with the gate on, the fields
that would describe the whole Chatfuel account rather than one workspace are refused outright with
AccountScopeBlocked — listing the deployment's own bots would hand one customer the list of all the
others.
For the variables that turn any of this on, see environment variables.