Documentation menu

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.

The three ways to obtain a 7-day session (all of them end at the same JWT)
RouteCredential it takesWhat it gives back
GET /api/auth/bot-login-linkAuthorization: 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.

The only header that carries a session
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 setsATF_API_KEY, any request that does not carry a bearer credential must present the same value inX-API-Key; anything else is 401 {"error":"unauthorized"}. The health routes,/api/version, /api/openapi.yaml and 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/me is 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.

Registered in api/main.go; auth rules in api/internal/handlers/middleware.go
MuxWrapped inWhat 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/RequireAuthEverything 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 + RequireAdminAdministration. Without server-verified admin rights the route set is unreachable, not merely hidden.
mux → health, version, systemnonePublic 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 defaults from api/internal/ratelimit/policy.go — operators can move them per class
ClassDefault budgetKeyed onWhen the backend is gone
auth30/min, burst 10Peer addressfail closed. The unauthenticated login/register/exchange surface, where brute force is cheap.
shield600/min, burst 120Peer addressfail 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.
read600/min, burst 120Verified user idfail local: an in-process limiter keeps enforcing, reported as degraded.
write120/min, burst 30Verified user idfail local. Writes reach the core and Telegram, and are legitimately rare.
admin300/min, burst 60Verified user idfail local. Still limited: an admin token in the wrong hands is the most valuable one.
probe1200/min, burst 300Peer addressfail local. Liveness and version polling, so a monitoring loop is never refused by a broken accelerator.
  • The quota is published, not discovered. Every answer carriesX-RateLimit-Limit and X-RateLimit-Remaining (on by default; an operator can turn the headers off with ATF_RATELIMIT_HEADERS); a refusal adds Retry-After andX-RateLimit-Reset, and the degraded path adds X-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 with code, as the rest of the API does with error_code.
  • Login-link minting has its own cooldown. A second mint inside 60 seconds is refused by the account-level cooldown — 429 with error_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.

api/internal/middleware/correlation.go
DetailValue
Accepted inbound headersX-Correlation-ID, X-Request-ID, then traceparent — first non-empty wins
Accepted format8–128 characters of a–z A–Z 0–9 _ - . :; anything else is discarded
Generated when absent or invalidcorr- + 16 random bytes in hex
Always on the responseX-Correlation-ID and X-Request-ID, both carrying the effective id
DownstreamAppended 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.

api/internal/handlers (error_code), api/internal/ratelimit (code)
StatusCodes seen in the repositoryWhen
401UNAUTHENTICATED, TOKEN_INVALID, LOGIN_CODE_REQUIREDNo 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.
403FORBIDDENThe 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.
400invalid_request, unknown_task_kind, invalid_argument, loop_detected, endpoint_blockedThe body or query was refused by the core. The core's own refusal token is passed through verbatim so a client can translate it.
404not_foundMissing — or simply not yours: an ownership failure answers exactly like a missing row.
429COOLDOWN or rate_limitedTwo different budgets: the per-account login-link mint cooldown (retry_after in the body, Retry-After in the header) and the rate limiter itself.
502grpc status UnavailableThe core is unreachable. Check /api/health/services before suspecting the token.
503rate_limit_unavailable, session_not_connectedThe limit could not be evaluated at all and the class fails closed — or a session exists but has no live Telegram client.
The authentication failure a client should handle first
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_TOKEN is read by the core's server interceptor and by the Go client, which attaches authorization: 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 isUNAUTHENTICATED before 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.

Log in, then read your own rules
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]'
Mint a fresh link, then spend its code
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 .token
Public readiness route — no token, ever
curl -s https://your-host/api/health/services | jq
# {
#   "status": "healthy",
#   "time": "2026-01-01T00:00:00Z",
#   "services": [ { "name": "core", "ok": true, … } ]
# }
Who is this token, and how long does it last
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" }