Skip to main content
Access control is the request-auth gate in front of a server. It resolves an inbound API key to an identity, loads that identity’s policy, and decides whether the request may reach its route. It is on by default; it reads live auth context (identity, the policy-version cache-buster, and live counters) from Redis and its durable policy bodies and route mappings from Postgres. This page explains the model. To turn the gate on and write a first policy, follow Set up access control.
For local development against tools that need no auth, turn the gate off with ACCESS_CONTROL_ENABLE=false — the quickstart does exactly this.
Every request the gate admits also writes one audit line to the server log: the principal the gate resolved (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:
  1. 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.
  2. 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.
  3. 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 (a 403 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 refused 401. 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/auth area: own keys, the tokens payload, claim-link creation, the read-only scopes listing, logout, and a provider’s own-credential routes. The seeded editor and viewer role 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. A viewer reaches 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.
A flag that contradicts the route’s public-ness — 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 registered authed=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:
Each field resolves on its own, most specific first: the operator’s override, then the budget the family’s routes declare, then the default — so an override naming only a limit keeps the declared burst. 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, and X-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.
Anything else fails closed to the socket address — an untrusted peer, a chain too short for the declared hop count, an entry naming no address — and the fallback is reported in the log, never silent. An IPv6 client buckets by its /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.
Application abuse, not volumetric attack. This limiter counts requests per client per family after they reach the app, which is what bounds scanning, credential stuffing, and a hot webhook. It is not a DDoS defence: a flood large enough to saturate your bandwidth, connection table, or worker pool has to be absorbed before the app sees it — by a CDN, an edge WAF, or your ingress. Self-hosted deployments put one in front of the runtime; the split between edge volumetrics and in-app abuse limits is the standard one.

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; the config env 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 not fenced — those routes are ordinary read/write class, made admin-only by the base-tier /api/auth control-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.
An 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.