Documentation menu
Getting started
New in v2.0
Feature deep dives
Reference
Account linking
Account linking and dashboard sign-in
How one Telegram account becomes one dashboard session: the bot mints a single-use login code and the client exchanges it for a 7-day JWT.
Verified against the checkout built for v2.2.4 · updated 20 Oct 2026
On this page
Overview
The dashboard and the mobile client authenticate with the same credential: a seven-day JWT that the Python core issues. The Go control plane never signs a token — it validates one over gRPC and turns the answer into the request's identity, so a session that both sides disagree about cannot exist.
- One issuer.
core/infrastructure/grpc_server/account_servicer.pysigns the token (HS256) with a seven-day TTL, and carries the signing key itself:ATF_JWT_SECRETwhen the operator set one, otherwise a random key generated once and kept in the database so a redeploy does not log everybody out. - One verifier. Every authenticated request re-validates the token —
api/internal/handlers/middleware.go#authMiddlewarecallsValidateTokenand stores the verifiedIdentityin the request context; the Python core also confirms the account still exists, so a deleted account cannot keep a session alive on the strength of a signature. - A link carries a code, never a token. A link in a Telegram chat lives in the chat history, in backups and in forwarded messages. So the bot puts a random single-use code in the link and the client trades that code for a session once.
- Linking is an explicit action. A link is minted only on an explicit request — the dashboard button in the bot,
/reauth, orGET /api/auth/bot-login-linkfrom a client that still holds a valid session. On genuine expiry the client shows the login screen — one explanation, one action — instead of looping or auto-opening a chat.
The linking flow
End to end, from a Telegram id that has never seen the dashboard to a browser that is signed in. TheAPI authentication reference covers the wire format of each step.
Open the bot and ask for the dashboard
The bot resolves your Telegram id, provisions an account row the first time it sees you, and mints one dashboard login code for that id. Nothing else is required — no username, no password, no email.
The account is created on first use (
_ensure_account/get_or_create_credentials), so there is no separate sign-up step for a Telegram user.The bot answers with a one-tap link
The code is the only secret in the link. It is random, single-use, valid for 600 seconds, and the bot reply is the only place its plaintext ever appears.
https://<your-host>/?code=<single-use code>If you already have a live link, the bot reuses it rather than minting a second one — see the cooldown below.
The page spends the code
The dashboard reads
?code=once at load and exchanges it immediately for a session token. Because a code is single-use, the exchange happens exactly once even if the page renders its root component twice.POST /api/auth/exchange-bot-token {"login_code": "<code>"}A missing code is
401 LOGIN_CODE_REQUIRED; an unknown, expired or already-used code is401 INVALID_LOGIN_CODE.You are signed in for seven days
The answer carries the JWT and its expiry. The client stores it, checks the expiry itself before rendering, and proves the token against
GET /api/auth/meso a revoked or stale session never renders a panel that dies on its first request.Later: re-authenticate, do not re-register
When the session has expired,
/reauthin the bot answers with one localized message and one fresh link. There is no password to remember and no second account to create.
The single-use login code
Codes live in web_login_codes and are minted and burned by one service (core/application/web_login_codes.py#WebLoginCodeService). The row is deliberately small, because everything that matters about a code is one of these six columns:
| Column | Type | Meaning |
|---|---|---|
code_hash | TEXT PRIMARY KEY | SHA-256 of the login code. The plaintext exists only in the bot's reply — a database read yields no working link. |
user_id | INTEGER NOT NULL | The Telegram account the code was minted for. This is the only thing that names the account; the exchange accepts no user id. |
created_at | INTEGER NOT NULL | Unix seconds. Also the anchor for the mint cooldown and for pruning. |
expires_at | INTEGER NOT NULL | Unix seconds, created_at + 600. |
used_at | INTEGER | Set by the single UPDATE that consumes the code. Non-null means the code is spent. |
purpose | TEXT NOT NULL DEFAULT 'dashboard' | Why the code was minted, so a future second purpose cannot be confused with a dashboard login. |
- Ten minutes, then dead. The TTL is
LOGIN_CODE_TTL_SECONDS = 600, long enough for a slow human and short enough that a leaked link stops working on its own. - Stored hashed. The code is
secrets.token_urlsafe(32)and the row holds only its SHA-256 digest, so a database read yields no working link. - Burned in the same statement that matches it.
UPDATE web_login_codes SET used_at=? WHERE code_hash=? AND used_at IS NULL AND expires_at>?is the gate: a racing second exchange matches zero rows and is refused, so one link opens one session. - Pruned, never accumulated. Rows older than the TTL plus an hour are deleted opportunistically when the next code is issued, and a failed prune never fails the issue.
Exchanging the code
The exchange is the only route where an unauthenticated client can end up holding a session, so it is deliberately narrow: one field in, a session or a refusal out.
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
# {
# "success": true,
# "token": "eyJhbGciOiJIUzI1NiIs…",
# "expires_at": 1780000000,
# "account": { "user_id": 5094837833, "username": "tg_5094837833" }
# }curl -s -X POST https://your-host/api/auth/exchange-bot-token \
-H 'Content-Type: application/json' -d '{}'
# {"error":"login_code required: open the dashboard from the bot",
# "error_code":"LOGIN_CODE_REQUIRED"}Cooldown & re-authentication
Minting is rate-limited per account: WebLoginCodeService.try_issue(uid, cooldown_seconds=60) refuses to issue a second code while an unused one minted in the last 60 seconds is still valid, and the API turns that refusal into 429. The point is not to throttle the user — it is that re-issuing would only invalidate the link the first click already gave them.
curl -s -D- -o- https://your-host/api/auth/bot-login-link \
-H "Authorization: Bearer $TOKEN"
# HTTP/1.1 429 Too Many Requests
# Retry-After: 42
# {"error":"a valid login link is already active",
# "error_code":"COOLDOWN","retry_after":42}Two paths mint a link, both with the same cooldown: the bot's /reauth command andGET /api/auth/bot-login-link (Bearer token, and only ever for the token's own user id). The link the first request returned stays valid for its full ten minutes — a repeat click answers with a countdown, it does not replace anything.
Passwords & Mini App
The bot link is the usual way in, but the same session can be obtained from three other places. All of them end at the identical seven-day JWT — there is no second, weaker kind of session.
| Route | Body | Answers |
|---|---|---|
POST /api/auth/register | {user_id?, username, password} | 201 with the account; INVALID_USERNAME, WEAK_PASSWORD, USERNAME_TAKEN, ALREADY_REGISTERED |
POST /api/auth/login | {username, password} | 200 with token, expires_at and the account; INVALID_CREDENTIALS, PASSWORD_REQUIRED |
POST /api/auth/tma | {init_data} | A verified Mini App session; refuses a future-dated or older-than-24h auth_date |
POST /api/auth/exchange-bot-token | {login_code} | The 7-day session for a bot-issued code; LOGIN_CODE_REQUIRED, INVALID_LOGIN_CODE |
PATCH /api/auth/change-password | {old_password, new_password} plus the caller's own Bearer token | 200; FORBIDDEN if the body names another account |
GET /api/auth/me | Bearer token | The verified identity: user_id, username, is_admin, now |
GET /api/auth/bot-login-link | Bearer token | A fresh single-use link for the caller's own id; 429 + COOLDOWN inside the mint window |
- A password login never moves the Telegram binding. Only the bot-issued proof paths may create one, because
telegram_user_idon a public password route is attacker-controlled; the field is ignored unless it already agrees with the stored binding. - The Mini App proves itself cryptographically.
POST /api/auth/tmaverifies the HMAC-SHA256 signature of Telegram'sinitDataagainst the configured bot token, refuses a duplicatehash, a futureauth_dateor one older than 24 hours, and only then extracts the numeric user id. - A web-only account owns nothing Telegram-side. Registering without a Telegram id stores the account under a synthetic negative id, so every rule, session, log and queue query — all filtered by the verified id — answers an empty collection rather than another tenant's data.
Session lifetime
A session is valid for seven days (_TOKEN_TTL = 7 * 24 * 3600). The core also refuses a token whose expiry is more than a day beyond that (_TOKEN_MAX_TTL) or whose iat is more than five minutes in the future, so a forged decade-long token is not a decade of access even if a signing key leaks.
On the client side one component owns the session: the stored token's exp is checked before any request, the token is proved against GET /api/auth/me on mount, and a burst of concurrent 401s produces exactly one session transition and one prompt rather than a redirect loop.
The bot-side login flow carries a revision guard of its own. The persisted flow row in auth_flows is written with the optimistic-concurrency comparison inside the statement (ON CONFLICT(user_id) DO UPDATE … WHERE excluded.version > auth_flows.version), so a late callback belonging to a flow that has already moved on cannot overwrite newer state and cannot silently lose an attempt count. The transition re-reads the winning revision and re-applies itself, bounded to three attempts.
Troubleshooting
| What you see | Why | What to do |
|---|---|---|
401 LOGIN_CODE_REQUIRED | The request body carried no login_code. | Ask the bot for the dashboard again; the link it answers with carries the code. |
401 INVALID_LOGIN_CODE | Unknown, expired (past 600s) or already used. All three answer identically on purpose, so the response cannot be used to probe which codes existed. | Mint a new link from the bot with /reauth. |
429 COOLDOWN | A link was minted for this account less than 60 seconds ago and is still valid. | Open the link already in the chat, or wait the Retry-After seconds. Nothing was invalidated. |
403 FORBIDDEN | The password-change body named a user id that is not yours. | Drop user_id from the body — the token already says who you are. |
| Login screen after seven days | The 7-day JWT expired. The dashboard drops an over-age token before it renders rather than letting the panel die on its first 401. | Send /reauth to the bot for one fresh link. |
502 on every auth route | The core service is unreachable, so no token can be issued or validated. | Check /api/health/services, then the core container logs. |