Mini App OIDC flow
The Mini Apps never talk to Supabase Auth and never see an OIDC token. They send Telegram's signed launch data to the API, and the API turns it into a Supabase session by acting as a custom OpenID Connect provider that only Supabase Auth talks to. This page explains that design; the endpoint and error reference is in Channel authentication.
The parties
| Party | Role in the flow |
|---|---|
| Telegram | Signs the Mini App launch data (initData) with an HMAC derived from the bot token |
| Mini App | Holds the launch data and, after sign-in, the Supabase tokens in memory |
| API — channel authentication | Verifies launch data, resolves a stable person, and is the OIDC issuer: signs ID-token-like assertions and publishes discovery and JWKS |
| Supabase Auth | Relying party: accepts the API's assertion through its id_token grant, verifies it against the API's JWKS, and issues the real Supabase session |
| API — business routes | Verify Supabase access tokens offline; never see launch data or assertions |
Why not a standard OIDC redirect flow? Telegram already authenticated the user
when it opened the Mini App, and Mini Apps have no browser redirect to an
authorization page. So the API skips the interactive part of OIDC and only uses
the piece Supabase Auth supports for native sign-in: the ID-token grant. The
/oauth/authorize and /oauth/token endpoints exist only because Supabase
requires them at registration; they always answer 400.
Sign-in sequence
Mini App API (issuer) Supabase Auth
| | |
| 1. POST /auth/telegram/challenges {app} |
|------------------------->| |
| {contextId, contextSecret, expiresAt} |
|<-------------------------| |
| | |
| 2. POST /auth/telegram/exchange |
| {contextId, contextSecret, initData} |
|------------------------->| |
| | 3. verify initData HMAC with the |
| | app's bot token; check age |
| | 4. resolve principal (stable person |
| | ID); bind proof to the context |
| | 5. sign RS256 assertion |
| | iss, sub=principal, aud, 60 s, |
| | jti, nonce=sha256(raw nonce) |
| | |
| | 6. POST /auth/v1/token |
| | ?grant_type=id_token |
| | {provider, id_token, nonce} |
| |------------------------------------>|
| | | 7. GET /.well-known/
| |<------------------------------------| openid-configuration
| |<------------------------------------| and jwks.json; verify
| |------------------------------------>| signature, iss, aud,
| | | exp, nonce
| | {access_token, refresh_token, |
| | expires_at, user.identities} |
| |<------------------------------------|
| | 8. check identity sub == principal |
| | 9. bind Supabase user to principal, |
| | user and client accounts |
| | 10. apply pending contact/permission|
| {accessToken, refreshToken, expiresAt} |
|<-------------------------| |
| | |
| 11. business requests: Authorization: Bearer <accessToken> |
|------------------------->| verified offline against Supabase |
Step by step:
- Challenge. The Mini App asks for an exchange context for its app ID
(
VITE_CHANNEL_AUTH_APP). The context lasts 10 minutes and allows 5 attempts. The app keeps it insessionStorageso a reload can reuse it. - Exchange. The Mini App sends the context and the raw
initData. The app ID comes from the context, never from the request body. - Launch proof. The API checks Telegram's HMAC (
WebAppDatakey derived from the bot token of that app) in constant time, and rejects launch data older thanCHANNEL_AUTH_TELEGRAM_MAX_AGE_SECONDSor dated in the future. - Principal. The Telegram user ID resolves to one stable principal, shared
by both bots. The launch data is then bound to this context, so another
context cannot replay it (
proof_consumed). - Assertion. The API signs a JWT with
CHANNEL_AUTH_ASSERTION_PRIVATE_KEY: headeralg: RS256with akidderived from the public key; claimsiss(CHANNEL_AUTH_ISSUER_URL),sub(principal ID),aud(CHANNEL_AUTH_ASSERTION_AUDIENCE),iat,exp60 seconds later, a uniquejti,nonce(SHA-256 of a fresh random value) and, optionally,name. It never contains email, phone or the Telegram ID. - ID-token grant. The API posts the assertion and the raw nonce to
Supabase Auth's
token?grant_type=id_tokenendpoint, namingCHANNEL_AUTH_SUPABASE_PROVIDERand sendingSUPABASE_PUBLISHABLE_KEYasapikey. Redirects are refused and the call times out after 10 seconds. - Relying-party checks. Supabase Auth reads the issuer's discovery document and JWKS from the API, verifies the signature, issuer, audience, expiry and that the nonce hashes to the claim, then creates or finds the Supabase user linked to that provider subject.
- Identity check. The API accepts the session only if the returned user
has an identity for the same provider whose
subis the principal. Email, phone and user metadata are never used to match anyone. - Account binding. The Supabase user is recorded for the principal, and the user and client accounts are bound for the app's role. If this fails, the API revokes the new session instead of returning it.
- Pending facts. Contact or bot permission received by webhook before this sign-in is applied. A failure here does not fail sign-in.
- Business calls. The Mini App receives only
accessToken,refreshTokenandexpiresAtand uses the access token as a bearer token.
Refresh and sign-out
Neither needs Telegram launch data or a new assertion:
| Mini App call | API call to Supabase Auth |
|---|---|
POST /auth/session/refresh {refreshToken} | token?grant_type=refresh_token — rotates the refresh token |
POST /auth/session/logout with the bearer token | logout?scope=local — revokes only this session |
The Mini App refreshes 60 seconds before the access token expires, shares one renewal among concurrent requests, and retries a rejected request once after renewing. If the refresh session is gone, it falls back to a new exchange with the launch data, or asks the user to reopen the app. Details are in Mini App sign-in.
Issuer endpoints
The API serves these at the root of CHANNEL_AUTH_ISSUER_URL, all with
Cache-Control: no-store so Supabase sees key rotations promptly:
| Endpoint | Content |
|---|---|
GET /.well-known/openid-configuration | issuer, jwks_uri, authorization_endpoint, token_endpoint, subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], and empty response and grant type lists |
GET /.well-known/jwks.json | Public RSA keys only: the current key first, then retired keys from CHANNEL_AUTH_ASSERTION_RETIRED_PUBLIC_KEYS_JSON |
GET/POST /oauth/authorize | Always 400 unsupported_response_type |
POST /oauth/token | Always 400 unsupported_grant_type |
Because Supabase Auth fetches these over the network, the issuer must be a publicly reachable HTTPS origin with no path — even in development.
Supabase provider registration
Supabase Auth must know the API as a custom OIDC provider before the first
exchange. Run from apps/api, with operator credentials:
npm run auth:provider
The script reads SUPABASE_URL, SUPABASE_SECRET_KEY,
CHANNEL_AUTH_ISSUER_URL, CHANNEL_AUTH_SUPABASE_PROVIDER and
CHANNEL_AUTH_ASSERTION_AUDIENCE, and creates or updates the provider through
Supabase's admin API with:
provider_type: oidc;issuer=CHANNEL_AUTH_ISSUER_URLand its discovery URL;client_id=CHANNEL_AUTH_ASSERTION_AUDIENCE;- nonce checking kept on, and email optional.
The provider ID must look like custom:<lowercase-name>. The secret key is
used only by this script and never belongs in the API runtime or a Mini App.
Security properties
- Assertions go only to Supabase. They live 60 seconds, are bound to a one-time nonce, and are never returned to the Mini App.
- The private key is the crown jewel. Whoever holds it can sign in as any existing principal. Keep it in its own secret and rotate it with Rotate the channel assertion key.
- Launch data is a bearer credential. It is exchanged once and only its hash is stored, but a copy stolen before the first exchange can be used. TLS, the age limit and keeping it out of URLs and logs limit that window.
- Access tokens outlive sign-out. Logout revokes the refresh session; an issued access token stays valid until it expires because business routes verify it offline.
Implementation status
The flow is implemented and tested, and has been proven against a local Supabase Auth instance. Hosted Supabase acceptance and a cutover rehearsal are still pending; see Channel authentication.