Documentation menu

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_SECRET when 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#authMiddleware calls ValidateToken and stores the verified Identity in 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, or GET /api/auth/bot-login-link from 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.

  1. 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.

  2. 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.

  3. 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 is 401 INVALID_LOGIN_CODE.

  4. 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/me so a revoked or stale session never renders a panel that dies on its first request.

  5. Later: re-authenticate, do not re-register

    When the session has expired, /reauth in 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:

web_login_codes — core/infrastructure/persistence/database.py (and the Postgres bootstrap)
ColumnTypeMeaning
code_hashTEXT PRIMARY KEYSHA-256 of the login code. The plaintext exists only in the bot's reply — a database read yields no working link.
user_idINTEGER NOT NULLThe Telegram account the code was minted for. This is the only thing that names the account; the exchange accepts no user id.
created_atINTEGER NOT NULLUnix seconds. Also the anchor for the mint cooldown and for pruning.
expires_atINTEGER NOT NULLUnix seconds, created_at + 600.
used_atINTEGERSet by the single UPDATE that consumes the code. Non-null means the code is spent.
purposeTEXT 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.

Exchange a bot-issued code for a session
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" }
# }
A missing code is a 401, not a 500
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.

A second mint inside the cooldown
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.

The authentication surface, as registered in api/main.go (authMux, mounted at /api/auth/)
RouteBodyAnswers
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 token200; FORBIDDEN if the body names another account
GET /api/auth/meBearer tokenThe verified identity: user_id, username, is_admin, now
GET /api/auth/bot-login-linkBearer tokenA 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_id on 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/tma verifies the HMAC-SHA256 signature of Telegram's initData against the configured bot token, refuses a duplicate hash, a future auth_date or 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

Every status code here is traceable to api/internal/handlers/auth.go
What you seeWhyWhat to do
401 LOGIN_CODE_REQUIREDThe request body carried no login_code.Ask the bot for the dashboard again; the link it answers with carries the code.
401 INVALID_LOGIN_CODEUnknown, 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 COOLDOWNA 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 FORBIDDENThe 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 daysThe 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 routeThe core service is unreachable, so no token can be issued or validated.Check /api/health/services, then the core container logs.