Skip to content
ChatfuelSDK
Concepts

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

QuestionWhat answers itWhat it asks
May this deployment touch that bot at all?the workspace fence, always onChatfuel, with the master token
Does this signed-in person own that bot?the auth gate, only with the auth moduleyour 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.

Both answer who may call. What a caller past them may then do — how much of the deployment they may spend, which operations exist, which ids inside a bot they may name — is a different set of questions, on ceilings and fences.

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 two unverified claims, which cost no network: exp, so an expired token is refused without a round trip, and iss, which must be this project's own {supabaseUrl}/auth/v1 — a token minted for somebody else's project is refused here for the same answer PostgREST would give after a round trip. Otherwise it asks your own Supabase project which bots this caller may open, through cf_my_bot_ids. PostgREST checks the signature; a bad or expired token comes back as its 401.

That RPC is one rule, written once in the migration and repeated by the two functions the app reads: a member of the workspace that holds the bot, who either administers the workspace — owner and admin reach every bot in it — or was granted this one.

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. On a WebSocket each of those is a close code instead; errors is the table.

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.

How long an answer is trusted

The gate caches by sha256 of the token, never the token itself, and every entry is bounded by the token's own exp — so a session that has expired is re-asked about rather than answered from a memory of when it was still valid. A grant is held for 30 seconds by default. A 401 is held for the same window and a 503 for a second, because an uncached refusal is a free round trip to your Supabase project for whoever sends the junk token, and a stream of them would make this proxy a way to point traffic at somebody else's project. Concurrent asks about one session share a single flight, and misses that reach Supabase are metered per minute, with part of that budget held for sessions the gate has already admitted.

A just-invited colleague is let in on their next request past the window rather than after a redeploy, and the provisioning route drops that session's entry the moment it attaches a bot — otherwise the app would spend the rest of the window being told its own brand-new bot is not its own.

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 that long. 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 the fence is read once, at connect. Somebody removed mid-session keeps their open socket until it drops. A grant the admin panel takes away is the exception: revoking one closes the live sockets holding that bot with 4401, which is the client's instruction not to retry with this session.

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.

The core of the module's migration is seven tables: profiles, workspaces, memberships, bots, bot grants, invites and its own migration ledger. Row-level security is on and grants are revoked, with no policies but two self-only ones on the profile mirror, so the cf_* functions are the entire read and write surface.

Two constraints carry more weight than they look:

  • cf_tenants.created_by is 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.

Bot creation is bounded twice, by two SQL functions the browser is never granted, checked before Chatfuel is ever asked: one caps a single workspace, the other the whole deployment. Underneath both, the ceiling is Chatfuel's own — every bot of every account is created inside one Chatfuel workspace, and a full plan answers WorkspaceFull to sign-ups and to "new bot" alike. The numbers and the variables that move them are on accounts.

What the gate does not answer

Be precise about what this buys, because the gap is not obvious.

The gate 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 says nothing about an operation that names a flow, a contact or a conversation instead. Those ids are issued per bot, and the upstream API will not resolve one to a bot for the proxy: behind the master token every bot belongs to the same account. That is the resource fence's question, and it is answered from what the proxy has watched being handed out — see ceilings and fences.

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 — and so are the fields that reach the account through a bot the caller does hold: its workspace's other bots, its Chatfuel team, its API token.

And /admin is outside all of it, with the module or without it. The panel never consults the gate and never applies the fence, because what it exists to show is the account-wide view they are built to withhold. Its own door is a password and a signed cookie — the admin panel.

For the variables that turn any of this on, see environment variables.

On this page