# 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.

Page: https://sdk.chatfuel.com/docs/concepts/auth-gate
Markdown: https://sdk.chatfuel.com/docs/concepts/auth-gate.md

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 [#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 [#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 [#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 [#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](/docs/deploy) covers reading the gate's state off
the startup line and the health route before anything else.

## Answers are cached for 30 seconds, deliberately [#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 [#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_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.

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 [#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](/docs/deploy/environments).
