For local development against tools that need no auth, turn the gate off with
ACCESS_CONTROL_ENABLE=false — the quickstart
does exactly this.anonymous when the request carried no identity
— a public route, or the gate turned off), the method, the route, the response
status, how long it took, and a timestamp. It is on by default and turns off with
TAI_AUDIT_LOG_ENABLE=false. The route is the only request text on the line, and it is
the path template: a path parameter is written as its {name}, so a door that
carries its credential in the path (a trigger link’s /trigger/{token}) records the
shape and never the secret. A stray path matches too on a deployment that serves the
Studio, where the SPA catch-all takes it: the line reads /{spa_path}, again never
the tail. A path nothing matched at all has no template to record, so the line reads
<unmatched> and carries no part of that path — as does a path handed on to a
mounted sub-server (/app/{slug}), which records the mount’s own prefix and then
that same constant, /app/<unmatched>, because what the sub-server made of the rest
is not visible from here. Nothing else reaches the
line — never a request or response body, a query string, a header, or a token — so
the trail is safe to ship wherever your logs already go.
A refusal is on the same trail, so failed access is visible, not just granted
access. A request the gate turns away writes its own line: a 401 when it carried
no usable identity, a 403 when a known identity is not permitted, or a 429 when
the flood limiter sheds it. A denial line adds a reason= naming the cause it can
state (a permission miss, an unconfigured route), and its principal is the real
caller when the request was authenticated-but-denied, otherwise the marker
unauthenticated. A refusal records the same path template (or <unmatched>), so
it never leaks a path-borne secret either.
Identity, policy, route
The gate has three moving parts, resolved on every request:- Identity — an inbound key is hashed and looked up; a stored key resolves to
a
user_id. A raw key is generated with a CSPRNG, only its SHA-256 is ever stored, and the plaintext is returned to the caller exactly once. - Policy — the identity’s policy carries its scopes plus an optional jq condition and policy data. The policy is cached per worker and keyed on a version, so a management edit bumps the version and the next read bypasses the stale cache without waiting out the TTL.
- Route — a request path maps to a scope id; the guard admits the request
only if the identity’s scopes cover it. A route pinned to the public marker
(via
tai scopes public-pin) is public;*is a superuser scope on a key, and it is also what a registered, authenticated route with no mapping resolves to — so every role-holder (which carries*) reaches such a route out of the box, while a scoped key needs an explicit row. Only a path the application does not serve fails closed for every ordinary identity — except a condition-free*super-admin key, which is admitted (a root can map the route anyway, so blocking it is a footgun, not security). So an access-control-enabled deployment’s admin reaches the whole surface out of the box, every role-holder reaches every registered route, and the route table only ever adds scoping for scoped keys.
Principals
Every credential resolves to a principal — the unit of identity the gate loads a policy for. A principal is either a human, which signs in through an accounts provider, or a service, which never logs in and holds keys only. Every API key belongs to exactly one principal — its owner, recorded on the key at mint — and a key with no owner is a validation error at mint and an invariant violation at read, never a silent default. The deployment’s first principal is the owner, created once through the setup door.Resolving a credential across providers
A deployment can enable more than one identity provider at once — an API-key store, an accounts provider’s sessions, an issuer-JWT verifier.ACCESS_CONTROL_AUTH_PROVIDERS is an ordered list, and the gate
resolves an inbound token by trying each provider in order, first match
wins: the first provider that recognises the token answers, and resolution
stops there.
The distinction that keeps this safe is error is not miss. A provider that
does not recognise a token returns “not mine” and the gate falls through to the
next provider. A provider that recognises the token but hits a backend failure
raises — it does not silently return “not mine”, which would let a
misconfigured store fall through to a weaker provider. A miss falls through; an
error fails closed.
Attenuation is enforced at the gate
When an accounts provider is in play, a resolved API key carries its owning account, and the gate applies owner attenuation on every request: the key’s effective authority is intersected with its owner’s live policy right here, at resolution time, before the policy decision. Because it is re-applied per request rather than frozen at mint, a change to the owner’s role — or disabling the owner — takes effect on the key’s very next call. Only the mint path may set the owner claim; the gate strips any owner claim a non-minting provider returns, so an external issuer can never assert ownership it did not earn. The owner every door reads is the one recorded on the key’s stored policy at mint. Where a credential is verified, the owner it carries must equal that stored owner: a credential whose owner claim differs is denied (a403 at the request
gate; a 400 where a key is named as input, as at claim-link creation) and the
mismatch is logged as an error, never resolved silently in favour of either copy.
A key that carries an owner claim is an owned key — a capped, isolated
sub-identity you can hand to a teammate, a device, or a machine. The delegation
model, the
GET /api/auth/me capability projection, per-identity data isolation,
the claim-link channels, and which scopes are safe to grant live on
Owned keys.The login routes are public; /api/auth stays reserved
Human login flows live under the /api/login namespace — the
methods listing, the credential-submit routes, the setup door and invite flows.
Each login route is public by its own declaration: a browser must reach it
before it has any credential, so the route says so where it is registered, not in
a route mapping an operator has to remember to add. The /api/auth provisioning and
policy routes stay reserved behind the gate — minting keys, editing policies,
and managing principals all require an authenticated, authorized caller. Sign-in is
open; administration is not.
Routes declare their reach
Beside its public-ness, a route declares the reach the gate gives it, with three flags on its registration (custom_route, or the plugin route module’s own call):
pre_auth— a pre-authentication route (login, recovery, claim exchange). A credential presented on it is never verified, so a stale session or key the browser still sends cannot lock a caller out of the door that replaces it; the route is public and no policy layer runs, at the HTTP edge and when it is reached as a tool alike. Only a public route may declare it. Every login route the platform and its accounts plugins ship declares it. Any other public route still verifies a presented credential, so it keeps the caller’s identity when one is sent.any_authenticated— reachable by any authenticated identity, whatever its scopes and whatever the route table maps:GET /api/auth/me(the capability projection) declares it, so a scoped key that holds no"*"can still introspect. The base-tier role condition still applies, and an unauthenticated caller is refused401. The reach is the same at the HTTP edge and when the route is reached as a tool — over MCP or by a background run: the scope test is skipped, and the jq conditions and the per-tag access-level pass still run. Only an authenticated route may declare it.self_service— a caller self-service surface under the reserved/api/autharea: own keys, the tokens payload, claim-link creation, the read-only scopes listing, logout, and a provider’s own-credential routes. The seedededitorandviewerrole conditions are built from these declarations on the methods each route serves, so a plugin that ships such a route is carved in without the platform naming it. Aviewerreaches only the state-changing ones among them; an admin-only action class (the policy history and rollback) still fences a route whatever it declares. A self-service route is not level-governed: the per-tag pass admits it for every role, and its tag counts toward no grant map on its account.
pre_auth on an authenticated
route, any_authenticated on a public one — is a registration error. The boot also
refuses a registry holding an authenticated route that declares pre_auth, the flag
that skips credential verification. None of these is a setting: an operator’s own
always-public prefixes (ACCESS_CONTROL_ALWAYS_PUBLIC_PATH_PREFIXES) still feed the
same credential skip, for a login page the deployment serves outside the registered
routes, and default to none.
The Studio SPA shell is public; its data is not
An unmatched non-/api GET is served the dataless Studio SPA shell —
index.html and its static assets — so a refresh or bookmark of an inner Studio
route (/agents, /settings) lands the app, not a 404. This is on by default
(the spa_shell_public setting) and safe: the shell carries no data — everything
it renders sits behind an authenticated /api/* call — so an unauthenticated
visitor sees only the login page. Deep links therefore work with no manual
public pin for / or any SPA path. Because the guard resolves deny-wins, an
over-broad protected mapping that also covers SPA paths suppresses this fallback, so
the refresh meets a 401 rather than the shell — scope a protected pattern to a real
surface, never the whole site. The fallback is GET-only and never touches the
control plane — /api plus the path the streamable-http MCP transport is mounted
at (/mcp by default, the configured path otherwise; an SSE-only deployment has
/api alone) — so it can never open it; a headless deployment
turns it off by setting spa_shell_public to false. See
deploying the Studio.
Routes declare their own public-ness
A route declares whether it answers unauthenticated at registration — a core native route registeredauthed=False (the interactions callback and served-media
capability doors), or a plugin route carrying its own public flag in the plugin’s
descriptor. The route registry records
that declaration against the route’s single owner, and the gate reads it as a tier of
its own: after the always-public prefixes and patterns resolve, and before the
Postgres route-table lookup, the gate matches the request (path, method) against
the registry — a match on a route declared public is served unauthenticated,
whatever its owner. So a core capability door or a plugin’s inbound webhook is open
the moment it registers, with no route row to add and no pin to remember.
The tier is per-method. It resolves only the exact methods a route declares
public: a POST declared public opens, while a GET on the same path that is not
declared public falls straight through to the normal gate. And because every route
has one owner — two owners can never
both claim one path shape and method — the declaration is the whole source of truth.
Nothing is ever written to the access_control_routes store for a declared-public
route, so there is no stored row to drift out of step with the code.
The declaration is authoritative: because the tier short-circuits above the
route-table lookup, an operator cannot re-protect a declared-public route in place
with a later route-table pin — the pin never gets consulted. A core capability
door’s declaration is fixed in the platform code; to close a plugin door, decline it
at the install-time public-acceptance gate or uninstall the plugin. Remapping only
relocates the public route, it does not protect it. The operator’s own hand-pin door
stays for the operator’s own routes.
A scope mapping would read as protecting such a route while it stays open, so the
scope writer refuses one: mapping a URL into a scope (POST /api/auth/scopes over
HTTP or as a tool, tai scopes add) is refused with a 400, nothing written, when a route
declared public serves that URL on any method, and an access_control restore names
such a mapping in its section’s errors and restores the rest. The routes listing
(GET /api/auth/routes, tai scopes routes) marks each route public by its own
declaration with declared_public: true, read through the same predicate as this
tier.
The reserved namespace still wins. A declared public route resolving under a
reserved prefix — /api/auth by default — is refused at boot, not quietly opened,
so no owner can declare its way past the reserved control plane described above. The
operator’s own public-pin door (tai scopes public-pin) is unchanged and
independent — it maps a route row public in the store, which is a separate tier from
a route’s own declaration.
Flood control on public doors
Every public door is rate-limited, and the coverage is derived, never listed: a route declares whether it is public, the route registry records that, and the limiter throttles every route registered public. A new public door — a plugin’s inbound webhook, another login door — is flood-limited the moment it registers. Authed routes pass straight through; the credential is the gate there. A request is answered by the most specific registered route that covers it, so a concrete door always outranks the SPA catch-all, and a path no registered route matches is not the limiter’s business.Door families
Public doors are grouped into families whose counters are disjoint, so a flood on one door can never exhaust another’s budget. A family is the door’s path stem — its leading static segments, at most two, stopping at the first{parameter}, with the
shared /api segment dropped:
Two segments is what keeps a deployment’s families disjoint without splitting one
door’s own surface, while every channel keeps a family of its own. A path
parameterised from its first segment — the SPA catch-all
/{spa_path} — has no stem
and folds into root.
A route may name its family instead (DeclaredRouteMetadata.rate_limit_family): the
web chat’s doors declare channels_web and its upload door channels_web_uploads, so
their families hold whatever mount base the plugin is installed under.
Budgets
A route declares its family’s default budget (DeclaredRouteMetadata.rate_limit_budget, a RateLimitBudget(limit=…, burst=…)), and
every public route of one family declares the same one: a family whose routes disagree —
or where one route declares and another forgets — fails the boot, or the reload that
installs it, before it serves. A family whose routes declare nothing is charged
600 requests a minute with a 120-request burst in any 10 seconds.
The runtime’s three single-request doors — trigger, universal_webhook, and
interactions_callback — declare 60 a minute and 10 in 10 seconds, because one request
there buys real work: a webhook fan-out, a ticket redemption, a tool run. The web chat
declares channels_web at 120 a minute and 30 in 10 seconds, wide enough for one first
page load (the chat page plus every bundle file it links in a single burst), and
channels_web_uploads at 20 and 5.
Over either window the door answers 429 with a Retry-After naming the seconds
left in that window, and the refusal writes its own audit line as an unauthenticated
request against the route template.
Tuning a family
TAI_RATE_LIMIT_FAMILIES overrides one family’s budget, as a JSON object or per
field:
TAI_RATE_LIMIT_DEFAULT_LIMIT,
TAI_RATE_LIMIT_DEFAULT_BURST, and TAI_RATE_LIMIT_DEFAULT_ENABLED move the default
every family whose routes declare nothing starts from. Switching a family off is the
operator’s alone: a route declares a budget, never an off switch.
An override tunes or disables the door it names; it never widens coverage.
{"enabled": false} passes that family through, and an entry for a family no route
declares is inert.
Which client a request is from
The counter is keyed per client address, andX-Forwarded-For is caller-writable
text — any client can claim any address in it — so it is believed only under a
declared trust statement:
TAI_RATE_LIMIT_TRUSTED_PROXIES— the addresses your proxies call from, single hosts or CIDR blocks. The socket peer must itself be trusted before a header byte is read; then the right-most entry that is not itself a trusted proxy is the client.TAI_RATE_LIMIT_TRUSTED_HOPS— the number of proxies sitting in front of the deployment. Use it when the proxy’s own address moves (a managed load balancer renumbers): the limiter skips exactly that many right-most hops instead of matching addresses. A positive value wins, and the roster is then not consulted.
/64, since one
host routinely holds a whole /64.
The same resolution keys the setup door’s wrong-token throttle and an accounts
provider’s login throttles, so one trust statement holds for every door that counts
per client.
With neither setting, X-Forwarded-For is ignored and the socket peer is the client:
correct for a directly exposed deployment, and behind a proxy it collapses every
client into one bucket. Boot emits a WARNING when no trust is declared, and a
forwarded header arriving under that posture is reported once more.
No Redis, no limiter
The counters live in Redis —TAI_RATE_LIMIT_REDIS_URL, or the shared
TAI_DEFAULT_REDIS_URL. With neither, rate limiting is OFF: every public door
passes through unthrottled and boot emits one WARNING naming the variable, never a
boot refusal. See what OFF looks
like and the
RateLimitSettings table.
Admin-only mutations
Beyond the editable per-tag role map, some routes are never grantable — no per-tag level opens them for any non-admin. The fence is declared on the route, not maintained as a hand-written list: every authed route declares an action-class, and two of the classes are the admin-only fence:fenced— the admin-only mutations: the pre-existing infrastructure changes and the role and policy control plane. Running a tool directly (POST /api/run-tool); reloading or removing a tool; theconfigenv write and reload (POST /api/config/env,/api/config/reload); replacing the manifest; reloading failed MCPs and deregistering an MCP; reloading the fleet; marketplace install / uninstall / update; backup import / export; and the role and policy control plane (creating, editing, deleting, and rolling back roles, and rolling back a user’s policy). Editing scopes, the route table, and the public-route pins is notfenced— those routes are ordinaryread/writeclass, made admin-only by the base-tier/api/authcontrol-plane ceiling that default-denies the area to every non-admin role.secret— the admin-only reads, over two admin-owned stores. The bulk role / policy reads: the roles listing (GET /api/auth/roles), the role version histories, and a user’s policy version history, whose payload carries raw jq policy bodies. And the deployment config store: the stored environment (GET /api/config/env) and the per-settings-class values (GET /api/config/settings-schema). Environment and settings are one admin-owned store — including deployment secrets such as the connector encryption key — so only an admin reads or writes it.
admin reaches all of them; an editor or viewer is denied. Everyday
feature configuration is not on this fence: starting an async tool-run
(POST /api/tool-runs), scheduling a tool, setting the MCP config, reloading a
single MCP server, mounting a sub-MCP, setting tool extensions, registering hooks,
and reading the hooks listing are ordinary grantable read/write routes an
editor reaches — an editor configures every feature through the feature’s own
routes. The deployment env/settings store is the admin-owned exception. A route
that declares no action-class fails boot, so the fence can never be silently
forgotten. See
user types and permissions for the full surface and the
per-tag role model.
Editors configure every feature; they do not own the deployment store. An
editor reaches the async tool-run surface, schedules, the MCP config, sub-MCP
mounts, tool extensions, and hooks through each feature’s own grantable routes —
the platform trusts a granted role to operate the features it holds. The
deployment env/settings store stays admin-only: reading the env and settings
(GET /api/config/env, GET /api/config/settings-schema) and the env write are
admin-only, alongside the infrastructure mutations and access-control
administration above.The jq policy
A policy is scopes plus rules. The rule is a jq condition evaluated against the request’s credentials and context — expressive enough to gate on identity, scope, and request attributes without any custom code. The condition is compiled and evaluated by the policy enforcer; a backend error while reading the policy version fails closed by raising, which surfaces as a clean deny rather than a silent default. The condition reads the request as.request.method and .request.path. The path is
the canonical request path, the same on every door that evaluates a condition (the
HTTP gate, a tool call, a background fire): any mount prefix the app is served under
(its ASGI root_path) is removed, each segment is percent-decoded once, repeated
slashes are collapsed, . and .. segments are resolved, and a trailing slash is
dropped. A condition written as .request.path | startswith("/api/auth") therefore
holds on a deployment mounted at /mnt exactly as at the root, and /api/auth/me/
reads as /api/auth/me.
A denied request never leaks internal detail: an authentication failure returns a
fixed 401 and an authenticated-but-forbidden request a fixed 403, with the full
failure logged only server-side.
Validate a jq condition offline before you commit it, and mint keys with the CLI:
Route mapping is two-tier
Mapping a route to a named scope (or pinning it public) is a configuration decision, not a property of the route code. It is two-tier: a path pattern maps a URL to a template key, and a Postgres record in the access-control policy store maps that template key to a scope id (or the public marker). A route’s own protection — authenticated-or-public, and its action class — is declared in the route code; the route table does not decide whether/api is private, it refines
scope for scoped keys. A registered authenticated route with no row is reachable
by every role-holder (it resolves to the universal scope); a row narrows it so a
key holding only named scopes can reach it too.
The MCP transports and the sub-MCP mount are authenticated surfaces the same
way: a role-holder reaches them out of the box, and a scoped key reaches them where
the operator maps them. This is how the interactions
callback doors are declared public while the stream and answer doors stay
protected.
Every management mutation that touches more than one key commits through a single
WATCH/MULTI/EXEC transaction, so a crash never leaves the store half-written and a
concurrent edit aborts and retries against fresh state.
See the access-control guide for the provisioning
workflow, the CLI reference for tai keys and
tai scopes, and the HTTP API reference for the routes.
