Skip to main content

Channel authentication

The API's channel-authentication slice (apps/api/src/features/channel-authentication) turns verified messaging-channel evidence, such as Telegram Mini App launch data, into a real Supabase Auth session. It runs inside the API process and container; there is no separate authentication service. Business routes never see that evidence: they verify only Supabase access tokens, as described in Supabase.

:::caution Not yet cut over The slice and both Mini App integrations are implemented and pass their tests, and the exchange has been proven only against a local Supabase Auth instance. Production use waits on two things that have not happened yet: hosted Supabase acceptance, described in the compatibility guide, and a rehearsal of the cutover runbook in a test environment. See Implementation status. :::

One slice, separate from business routes​

The slice's only job is mapping a channel user to a Supabase Auth user. It is kept apart from the rest of the API in three ways:

  • One token type for business routes. Business guards never accept an identity assertion or channel proof as an end-user credential. Every client, website or Mini App, presents the same kind of Supabase access token.
  • No direct writes to other slices' tables. The slice binds users, client accounts and contact only by calling the exported use cases of the users and client-accounts slices. A test checks that its SQL touches only the channel_auth schema.
  • No fact-accepting route. No HTTP route accepts a verified contact, permission or identity binding from a caller. Those facts come only from verified bot updates processed inside the API.

The Mini Apps call the same API origin for sign-in and for business requests.

How sign-in flows​

  1. A Mini App asks for a short-lived exchange context for its app, then sends the context and the raw launch data.
  2. The API finds the context's app and verifies the launch data against the bot configured for that app. It resolves a stable person identity, binds the launch proof to the context, and signs a short-lived assertion. That assertion is sent only to Supabase Auth.
  3. The API exchanges the assertion at Supabase Auth's ID-token grant and checks that the returned provider identity names the same person.
  4. Before it answers, the slice records the Supabase user for the person and binds it to the application accounts through the users and client-accounts use cases. A launch through the professional bot may enroll a professional; a launch through the client bot creates or binds the client account.
  5. Any contact or permission shared before this sign-in is applied to the account. Sign-in does not fail if that step fails; the next update or sign-in applies it.
  6. The Mini App receives only accessToken, refreshToken, and expiresAt, and calls business routes with the access token.

If Supabase Auth or account binding fails, the API keeps the person's identity and any Supabase user it already recorded, revokes a session it will not return, and answers with an error. A retry with the same context reaches the same Supabase user, so no duplicate account appears.

The compatibility guide shows how this exchange was proven against a local Supabase Auth instance. Supabase Auth requires the issuer to be HTTPS on a publicly routable address, and its discovery document to list authorization and token endpoints. The API serves both endpoints only to reject every request: it never runs an interactive sign-in flow. The issuer is the API's public origin, so discovery and keys are served at the origin's root.

Public endpoints​

All bodies are JSON. Session and issuer responses are Cache-Control: no-store. The wire shapes are defined in @template-harness/contracts/channel-authentication. Session routes refuse unknown body fields instead of dropping them.

EndpointBehavior
POST /auth/telegram/challengesBody {"app"}. Creates an exchange context for a configured app and answers 201 with contextId, contextSecret, and expiresAt. The context lasts 10 minutes and allows 5 exchange attempts. Only a hash of the secret is stored
POST /auth/telegram/exchangeBody {"contextId", "contextSecret", "initData"}. The app comes from the context, never from the body. Answers 200 with the session only after the accounts are bound
POST /auth/session/refreshBody {"refreshToken"}. Rotates the Supabase refresh session without launch data and answers 200 with the new session
POST /auth/session/logoutAuthorization: Bearer <accessToken>, no body. Revokes the caller's current refresh session and answers 204. A session that is already gone also answers 204
GET /.well-known/openid-configurationIssuer discovery for Supabase Auth
GET /.well-known/jwks.jsonCurrent and retired public assertion keys, public members only
GET/POST /oauth/authorize, POST /oauth/tokenAlways 400 with unsupported_response_type or unsupported_grant_type. No code, token, or redirect is ever issued
POST /webhooks/telegram/:appTelegram's webhook for that app's bot. Requires the app's X-Telegram-Bot-Api-Secret-Token, otherwise 401. Answers 200 once the update is stored and applied, or ignored, and 500 otherwise, so Telegram retries

Session bodies are limited to 16 KiB. These routes exist only where CHANNEL_AUTH_ISSUER_URL is configured.

A successful exchange or refresh looks like this:

{
"accessToken": "<supabase-access-token>",
"refreshToken": "<supabase-refresh-token>",
"expiresAt": "2026-10-21T13:00:00.000Z"
}

Errors carry a code and whether repeating the same request later may succeed:

{ "error": "temporarily_unavailable", "retryable": true }
StatuserrorretryableMeaning
400invalid_requestfalseThe body does not match the contract
400unknown_appfalseThe app is not configured
401invalid_prooffalseLaunch data is malformed, altered, stale, future-dated, or signed by another app's bot
401context_invalidfalseThe context is unknown, expired, out of attempts, already used by another person, or the secret is wrong
401session_invalidfalseSupabase refused the refresh token, the access token, or the user
409proof_consumedfalseAnother context already used this launch data
429rate_limitedtrueToo many requests from this address
503temporarily_unavailabletrueSupabase Auth or the database could not complete the request
503temporarily_unavailablefalseAn identity conflict needs an operator

After session_invalid, a Mini App clears its session and starts over with fresh launch data, or asks the user to reopen it. It never retries the same refresh token.

Replay protection and its limits​

Telegram launch data is a bearer credential: whoever holds it first can use it. The API narrows that window but cannot remove it.

  • One context per proof. The first exchange binds the launch data to its context. The same context may retry, for example after a lost response, and reaches the same Supabase user. Any other context gets proof_consumed.
  • Representation does not matter. Launch data is identified by its decoded, sorted fields and the bot, so reordering or re-encoding the fields does not make it new.
  • Bounded lifetimes. Launch data is accepted for CHANNEL_AUTH_TELEGRAM_MAX_AGE_SECONDS (default one hour). Its consumption record is kept until then. Contexts last 10 minutes and allow 5 attempts.
  • Rate limits. Per client address and API instance: 10 contexts, 20 exchanges, and 30 refreshes per minute. With several instances, the effective limit multiplies. In production the API trusts exactly one forwarding hop, the TLS-terminating proxy, to find the client address.

What this does not protect against:

  • Theft before first use. Launch data copied before the Mini App exchanges it can be exchanged by someone else, from their own context. TLS, short freshness, origin allowlists, and keeping launch data out of URLs and logs reduce that risk.
  • Issued access tokens. Logout revokes the refresh session, but an access token already issued stays valid until it expires, because the API verifies it offline.
  • Reuse after logout. A retry cannot be told apart from a sign-in after logout within the same context. A Mini App must discard its context when it logs out; the same launch data then cannot sign in again through a new context. A fresh launch can.

Contact and bot permission​

Contact and bot permission reach the API only through bot updates, never through a Mini App request. These facts are trusted from an authenticated webhook call:

  • Verified contact. In a private chat, the sender shares their own contact: the contact's user ID equals the sender's, and it is not forwarded. The phone must normalize to E.164. Anything else, such as someone else's contact card, a group chat, or an invalid phone, is acknowledged and ignored.
  • Bot write permission. Granted by write_access_allowed or by the user starting the bot again, and revoked when the user blocks the bot. Permission belongs to the bot that reported it and never transfers to the other bot.

Each update is recorded once per bot, by Telegram's update_id. Facts are ordered by Telegram's timestamp and then by update_id, so an older update that arrives late cannot overwrite newer state, such as restoring a permission the user has since revoked. Every accepted change gets the next version number for that person, bot and fact type.

Applying facts to accounts​

Each recorded fact also stores the highest version applied to the person's account. Applying works the same way from the webhook and from sign-in:

  1. Under a lock for that person, read each bot's latest fact that has not been applied yet. Only facts of a person with a Supabase user are read.
  2. Apply it through the client-accounts use case. A fact from an app that is no longer configured stays pending. Client-bot contact sets the verified phone and its verification time; client-bot permission sets the bot write permission. Professional-bot facts have no account state in the API today and are only marked applied.
  3. Mark the fact applied only after the account write succeeded.

A fact stays pending, without an error, while the person has no client account; their first client sign-in creates the account and applies it. A fact can also arrive before the person ever opens a Mini App. The API then creates the person's stable identity and keeps the fact until their first sign-in.

The webhook answers 200 only after the account write succeeded. If it fails, the webhook answers 500; Telegram redelivers the update, which is then a duplicate and records nothing new, but still applies whatever is pending. Telegram keeps retrying for up to a day. There is no outbox, queue, or delivery worker.

See Troubleshoot contact onboarding for inspecting pending facts.

Who owns which data​

Ordered files in supabase/migrations/ create every table. Supabase owns the migration history.

SchemaOwnerHolds
channel_authchannel-authentication slicePrincipals and their Supabase user, provider identities, exchange contexts, consumed-proof hashes, processed Telegram update IDs, verified channel contacts and per-bot permissions with their source and applied versions, and fact source versions
publicOther API slicesUsers, professional enrollment, client accounts, bookings and claims

Identity rules the schema enforces:

  • One principal per provider identity. A provider identity is unique by provider, provider scope and provider subject. For Telegram the scope is the shared user-ID namespace, so both bots resolve to the same principal. The bot is verification context, not part of the key. Names, usernames and phones are never keys.
  • One Supabase user per principal, and the reverse. The Supabase user is recorded only after Auth returns it for the principal's subject, and is never reassigned. Concurrent first launches converge on one principal.
  • Client accounts bind to one Supabase subject. Contact can be incomplete: a verified phone and its verification time are stored together or not at all. Telegram routing fields are optional and no longer authenticate anyone.
  • Professional access is explicit. A professional_enrollments row grants it, with source professional_app (a verified professional-bot launch) or website (an explicit website request). A users row alone grants nothing. No enrollment was backfilled: professionals keyed telegram:<id> from the retired API-issued sessions are neither enrolled nor remapped, and sign in as new people.

The API uses its usual database connection. The browser-facing anon and authenticated roles cannot see the channel_auth schema or professional_enrollments. Expired proofs, unused expired contexts and processed updates older than at least two days are removed only by the retention function channel_auth.purge_expired.

Who owns each secret​

SecretHeld byNever present in
Telegram bot tokens and webhook secretsAPI configuration (CHANNEL_AUTH_TELEGRAM_APPS_JSON)Mini Apps, responses, logs
Assertion signing private keyAPI configuration, as its own secretMini Apps, responses, logs
Supabase publishable keyAPI (exchange, refresh, logout)Logs
Supabase secret/admin keyOperators only, for provider setupAPI runtime, Mini Apps
Supabase access and refresh tokensThe Mini App that started the exchange, in memoryLogs, storage that outlives the tab, other clients
Raw launch dataPasses through the API once; only a hash is keptLogs, URLs, the database

Notification eligibility is separate from authentication. A client account's optional Telegram user ID and its client-bot write permission decide whether a Telegram notification may be sent to that person; neither authenticates anyone. The API itself has no Telegram message-sending adapter today, so bot tokens are used only to verify launch data.

The assertion key​

The API holds one channel-authentication private key, an RSA key used with RS256. It signs the 60-second identity assertions that Supabase Auth verifies through the published JWKS. Assertions carry only the issuer, the person's stable identifier, the provider audience, issue and expiry times, a unique ID, the hash of a fresh nonce, and optionally a display name. Never email, phone, or a Telegram ID.

The key is never published, logged, or returned. Anyone holding it can sign in as any existing channel identity, so keep it in its own secret, separate from business configuration. See Rotate the channel assertion key.

What is enforced today​

These defaults are implemented and covered by colocated tests in apps/api/src/features/channel-authentication and apps/api/src/shared/env.test.ts:

  • Configuration. The issuer must be an HTTPS origin with no path, credentials, query, or fragment, even locally. Channel authentication is required in production. Configuration errors name the variable and never print its value.
  • Request limits. Session bodies are limited to 16 KiB and webhook bodies to 64 KiB.
  • Responses. Session and issuer responses are Cache-Control: no-store. Errors never echo request bodies, tokens or proof; failures are logged by error name only.

For the variables themselves, see Environment variables.

Implementation status​

As of the archived change unify-channel-authentication (2026-10-22):

AreaState
API slice, migrations, guards, both Mini AppsImplemented. API unit tests, the database integration suite on a freshly reset local database, and both Mini App test and build scripts pass
Local Supabase Auth (v2.197.0)Passed with the harness-served issuer and again with the API as the issuer, behind a local TLS proxy; see the compatibility guide
Hosted Supabase and real Telegram webviewsNot run. No hosted acceptance evidence exists
Cutover runbookWritten, not rehearsed. No rehearsal report exists
Interim apps/auth_gateway workspaceIts source directory is gone from the repository, but package-lock.json still lists an auth-gateway workspace, so an existing install can keep a dangling node_modules/auth-gateway link. A clean install has not been verified. The API image is the only deployable that contains channel authentication