Documentation menu
Getting started
New in v2.0
Feature deep dives
Reference
Authentication
API authentication
Authenticating against the control plane: the bearer JWT, which routes stay public, the rate-limit classes, correlation ids and the gRPC credential.
Verified against the checkout built for v2.2.4 · updated 20 Oct 2026
On this page
Overview
The Python core owns identity: it issues the seven-day session token and answers "is this token valid, and for whom?". The Go control plane owns enforcement: it presents the token to the core on every request and scopes the call to the identity that comes back. There is no path where a client asserts who it is.
102 routes parsed from api/main.go68 require a token7 on the public auth mux
One consequence is worth stating early, because it explains most of the behaviour on this page: the token is atenant key, not a role. Everything tenant-scoped is filtered by the verified user id server-side, and an ownership failure looks exactly like a missing row.
Obtaining a token
Two routes mint a session, and neither of them is a user id you can post. The bot link is the usual one; the password and Mini App paths exist alongside it for clients that have their own proof.
| Route | Credential it takes | What it gives back |
|---|---|---|
GET /api/auth/bot-login-link | Authorization: Bearer <expiring session> | Mints the caller's own single-use code and answers {code, expires_at, link}. This is the re-authentication path: a client whose stored session is dead asks the bot side for one fresh link instead of looping the user through a login form. |
POST /api/auth/exchange-bot-token | {login_code} | Trades that code for the 7-day session. The body carries no user id — a Telegram id is public, so the code is the only thing that names the account. |
POST /api/auth/login | {username, password} | The username/password path for accounts that registered without Telegram (or alongside it). A password login never creates or moves a Telegram binding. |
POST /api/auth/tma | {init_data} | The Telegram Mini App path. The signed initData is verified (HMAC-SHA256 over the sorted data-check-string, duplicate and stale auth_date refused) before a token is issued for the numeric user id inside it. |
Sending the token
One header, on every authenticated request. The middleware validates it before the handler runs, and the handler only ever sees the verified identity — never a header or body field that claims one.
curl -s https://your-host/api/rules \
-H "Authorization: Bearer $TOKEN" | jq
# A rejected credential never reaches a handler:
# 401 {"error": "invalid or expired token"}
# (ValidateToken consults the core, and the core also checks the
# account still exists, so a deleted account cannot keep a session)- A shared key can replace it for machines. When the operator sets
ATF_API_KEY, any request that does not carry a bearer credential must present the same value inX-API-Key; anything else is401{"error":"unauthorized"}. The health routes,/api/version,/api/openapi.yamland the whole/api/auth/surface are exempt, and a bearer request is passed straight to the JWT path — the key is a machine gate, not a second factor. - Store it once, treat it like a password. The shipped clients keep the session in one place, check the token's own expiry before rendering, and prove it against the API on load, so a revoked session produces one login prompt instead of a page that dies mid-render.
GET /api/auth/meis the cheap probe. It is the intended way to answer "is this token still a session?": it returns the verified identity or a refusal. A client that stores a token should call it on mount and treat a refusal as one session transition, not as a per-request retry loop.- The token is a credential, not a preference. It identifies a tenant, so anything that holds it — a browser, a phone, a CLI — should hold it the way it would hold a password: no logging, no URLs that end up in chat history, no copies left in build artefacts.
Public vs authenticated
The muxes are the contract. /api/auth/ is mounted without a bearer wrapper (each of its handlers decides for itself), the service-health and system routes are public, and everything else under /api/ is wrapped in RequireAuth. The administration plane adds a second gate,RequireAdmin, on top of it.
| Mux | Wrapped in | What it means |
|---|---|---|
authMux → /api/auth/ | rate limit only (auth class) | Public surface. Its handlers do their own work: GET /bot-login-link and PATCH /change-password read the bearer token themselves, because their authorisation is "the caller's own account" rather than "some valid session". |
dashMux → /api/ | RequireAuth | Everything tenant-scoped: rules, sessions, filters, tasks, queue, dead letters, logs, prompts, diagnostics, backup. A missing or invalid token is refused before the handler is entered. |
adminMux → /api/admin/ | RequireAuth + RequireAdmin | Administration. Without server-verified admin rights the route set is unreachable, not merely hidden. |
mux → health, version, system | none | Public by design: /healthz, /health, /api/version, /api/health/services (and its /api/v1/ alias), /api/system/setup, /api/system/env, /api/openapi.yaml. |
Rate limits
The limiter is a Redis-backed token bucket with a circuit breaker, keyed on the verified identity — never on a client-supplied field — so ?user_id=, X-User-Id or a body field cannot move a caller into another tenant's budget. Requests that have not been authenticated yet are keyed on the peer address, and only a proxy the operator declared trusted may move that address.
| Class | Default budget | Keyed on | When the backend is gone |
|---|---|---|---|
auth | 30/min, burst 10 | Peer address | fail closed. The unauthenticated login/register/exchange surface, where brute force is cheap. |
shield | 600/min, burst 120 | Peer address | fail closed. The outer per-address guard, which runs before any token is validated so a flood of bogus tokens cannot make the API hammer the core. |
read | 600/min, burst 120 | Verified user id | fail local: an in-process limiter keeps enforcing, reported as degraded. |
write | 120/min, burst 30 | Verified user id | fail local. Writes reach the core and Telegram, and are legitimately rare. |
admin | 300/min, burst 60 | Verified user id | fail local. Still limited: an admin token in the wrong hands is the most valuable one. |
probe | 1200/min, burst 300 | Peer address | fail local. Liveness and version polling, so a monitoring loop is never refused by a broken accelerator. |
- The quota is published, not discovered. Every answer carries
X-RateLimit-LimitandX-RateLimit-Remaining(on by default; an operator can turn the headers off withATF_RATELIMIT_HEADERS); a refusal addsRetry-AfterandX-RateLimit-Reset, and the degraded path addsX-RateLimit-Degraded: 1. - A refusal is a 429 with a body, not an empty one.
{"error":"rate limit exceeded","code":"rate_limited","retry_after_seconds":…,"limit":…,"scope":…}. The limiter names itself withcode, as the rest of the API does witherror_code. - Login-link minting has its own cooldown. A second mint inside 60 seconds is refused by the account-level cooldown —
429witherror_code: "COOLDOWN"and aretry_after— before the limiter is even the interesting reason. Seeaccount linking.
Correlation ids
Every request ends up with one correlation id, whether the caller supplied one or not. It is echoed on the response, attached to the outgoing gRPC metadata, and recorded in the access trail, so an API call and the delivery it caused can be joined without guessing at timestamps.
| Detail | Value |
|---|---|
| Accepted inbound headers | X-Correlation-ID, X-Request-ID, then traceparent — first non-empty wins |
| Accepted format | 8–128 characters of a–z A–Z 0–9 _ - . :; anything else is discarded |
| Generated when absent or invalid | corr- + 16 random bytes in hex |
| Always on the response | X-Correlation-ID and X-Request-ID, both carrying the effective id |
| Downstream | Appended to every outgoing gRPC call as the x-correlation-id metadata key |
Error model
Refusals are JSON with an error string a human can read and, where the failure has a machine meaning, a code a client can translate. Two families exist and they are not interchangeable: handlers answererror_code (and the core's own refusal token is passed through verbatim), while the rate limiter answerscode. Some early refusals — a missing bearer header, an unparseable body, a gRPC failure mapped bygrpcError — carry only error, so a client should treat the code as optional and the status line as authoritative.
| Status | Codes seen in the repository | When |
|---|---|---|
401 | UNAUTHENTICATED, TOKEN_INVALID, LOGIN_CODE_REQUIRED | No credential, a rejected scheme, or a token the core would not validate. A missing login code is its own code so a client can prompt for a new one. |
403 | FORBIDDEN | The request is legitimate but names something that is not yours — an admin route without admin rights, or a password-change body carrying another account's id. |
400 | invalid_request, unknown_task_kind, invalid_argument, loop_detected, endpoint_blocked | The body or query was refused by the core. The core's own refusal token is passed through verbatim so a client can translate it. |
404 | not_found | Missing — or simply not yours: an ownership failure answers exactly like a missing row. |
429 | COOLDOWN or rate_limited | Two different budgets: the per-account login-link mint cooldown (retry_after in the body, Retry-After in the header) and the rate limiter itself. |
502 | grpc status Unavailable | The core is unreachable. Check /api/health/services before suspecting the token. |
503 | rate_limit_unavailable, session_not_connected | The limit could not be evaluated at all and the class fails closed — or a session exists but has no live Telegram client. |
curl -s -o /dev/null -w '%{http_code}\n' https://your-host/api/rules
# 401
curl -s https://your-host/api/rules | jq
# { "error": "authentication required" }Internal gRPC credential
The control plane talks to the Python core over gRPC, and every servicer there takes the acting tenant from a request field (owner_user_id) — which the API stamps from the identity it verified. That contract alone could not tell the API from any other process that can open a socket to the core, so the surface can additionally require a shared credential.
- One variable, both sides.
ATF_CORE_RPC_TOKENis read by the core's server interceptor and by the Go client, which attachesauthorization: Bearer <token>to every unary and streaming call. A deployment cannot enable it on one side by accident: a mismatch fails every RPC loudly at deploy time. - Unset is the old behaviour, said out loud. With no token configured the interceptor is not installed and the core logs a warning explaining that anything on the network can act as any tenant. Nothing is silently assumed to be fine.
- Refusals keep their shape. A missing, malformed or wrong credential is
UNAUTHENTICATEDbefore the servicer runs; the comparison is constant-time, the value is never logged, and the refusal preserves the method's cardinality so a streaming call does not turn into an internal error. - The logger is a separate trust domain. It is dialled without the credential on purpose: the secret authenticates this API to the core, and the logger does not need it.
Copy-paste examples
Three worked calls against a real deployment: obtain a session, use it, and read the public readiness route.
TOKEN=$(curl -s -X POST https://your-host/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"tg_5094837833","password":"…"}' | jq -r .token)
curl -s https://your-host/api/rules \
-H "Authorization: Bearer $TOKEN" | jq '.rules[0]'curl -s https://your-host/api/auth/bot-login-link \
-H "Authorization: Bearer $TOKEN" | jq
# { "code": "…", "expires_at": 1780000600, "link": "/?code=…" }
curl -s -X POST https://your-host/api/auth/exchange-bot-token \
-H 'Content-Type: application/json' \
-d '{"login_code":"<code from the bot>"}' | jq -r .tokencurl -s https://your-host/api/health/services | jq
# {
# "status": "healthy",
# "time": "2026-01-01T00:00:00Z",
# "services": [ { "name": "core", "ok": true, … } ]
# }curl -s https://your-host/api/auth/me \
-H "Authorization: Bearer $TOKEN" | jq
# { "user_id": 5094837833, "username": "tg_5094837833",
# "is_admin": false, "now": "2026-01-01T00:00:00Z" }