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
usersandclient-accountsslices. A test checks that its SQL touches only thechannel_authschema. - 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
- A Mini App asks for a short-lived exchange context for its app, then sends the context and the raw launch data.
- 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.
- The API exchanges the assertion at Supabase Auth's ID-token grant and checks that the returned provider identity names the same person.
- Before it answers, the slice records the Supabase user for the person and
binds it to the application accounts through the
usersandclient-accountsuse cases. A launch through the professional bot may enroll a professional; a launch through the client bot creates or binds the client account. - 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.
- The Mini App receives only
accessToken,refreshToken, andexpiresAt, 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.
| Endpoint | Behavior |
|---|---|
POST /auth/telegram/challenges | Body {"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/exchange | Body {"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/refresh | Body {"refreshToken"}. Rotates the Supabase refresh session without launch data and answers 200 with the new session |
POST /auth/session/logout | Authorization: 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-configuration | Issuer discovery for Supabase Auth |
GET /.well-known/jwks.json | Current and retired public assertion keys, public members only |
GET/POST /oauth/authorize, POST /oauth/token | Always 400 with unsupported_response_type or unsupported_grant_type. No code, token, or redirect is ever issued |
POST /webhooks/telegram/:app | Telegram'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 }
| Status | error | retryable | Meaning |
|---|---|---|---|
400 | invalid_request | false | The body does not match the contract |
400 | unknown_app | false | The app is not configured |
401 | invalid_proof | false | Launch data is malformed, altered, stale, future-dated, or signed by another app's bot |
401 | context_invalid | false | The context is unknown, expired, out of attempts, already used by another person, or the secret is wrong |
401 | session_invalid | false | Supabase refused the refresh token, the access token, or the user |
409 | proof_consumed | false | Another context already used this launch data |
429 | rate_limited | true | Too many requests from this address |
503 | temporarily_unavailable | true | Supabase Auth or the database could not complete the request |
503 | temporarily_unavailable | false | An 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_allowedor 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:
- 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.
- Apply it through the
client-accountsuse 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. - 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.
| Schema | Owner | Holds |
|---|---|---|
channel_auth | channel-authentication slice | Principals 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 |
public | Other API slices | Users, 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_enrollmentsrow grants it, with sourceprofessional_app(a verified professional-bot launch) orwebsite(an explicit website request). Ausersrow alone grants nothing. No enrollment was backfilled: professionals keyedtelegram:<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
| Secret | Held by | Never present in |
|---|---|---|
| Telegram bot tokens and webhook secrets | API configuration (CHANNEL_AUTH_TELEGRAM_APPS_JSON) | Mini Apps, responses, logs |
| Assertion signing private key | API configuration, as its own secret | Mini Apps, responses, logs |
| Supabase publishable key | API (exchange, refresh, logout) | Logs |
| Supabase secret/admin key | Operators only, for provider setup | API runtime, Mini Apps |
| Supabase access and refresh tokens | The Mini App that started the exchange, in memory | Logs, storage that outlives the tab, other clients |
| Raw launch data | Passes through the API once; only a hash is kept | Logs, 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):
| Area | State |
|---|---|
| API slice, migrations, guards, both Mini Apps | Implemented. 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 webviews | Not run. No hosted acceptance evidence exists |
| Cutover runbook | Written, not rehearsed. No rehearsal report exists |
Interim apps/auth_gateway workspace | Its 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 |