Skip to main content

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​

PartyRole in the flow
TelegramSigns the Mini App launch data (initData) with an HMAC derived from the bot token
Mini AppHolds the launch data and, after sign-in, the Supabase tokens in memory
API — channel authenticationVerifies launch data, resolves a stable person, and is the OIDC issuer: signs ID-token-like assertions and publishes discovery and JWKS
Supabase AuthRelying 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 routesVerify 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:

  1. 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 in sessionStorage so a reload can reuse it.
  2. Exchange. The Mini App sends the context and the raw initData. The app ID comes from the context, never from the request body.
  3. Launch proof. The API checks Telegram's HMAC (WebAppData key derived from the bot token of that app) in constant time, and rejects launch data older than CHANNEL_AUTH_TELEGRAM_MAX_AGE_SECONDS or dated in the future.
  4. 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).
  5. Assertion. The API signs a JWT with CHANNEL_AUTH_ASSERTION_PRIVATE_KEY: header alg: RS256 with a kid derived from the public key; claims iss (CHANNEL_AUTH_ISSUER_URL), sub (principal ID), aud (CHANNEL_AUTH_ASSERTION_AUDIENCE), iat, exp 60 seconds later, a unique jti, nonce (SHA-256 of a fresh random value) and, optionally, name. It never contains email, phone or the Telegram ID.
  6. ID-token grant. The API posts the assertion and the raw nonce to Supabase Auth's token?grant_type=id_token endpoint, naming CHANNEL_AUTH_SUPABASE_PROVIDER and sending SUPABASE_PUBLISHABLE_KEY as apikey. Redirects are refused and the call times out after 10 seconds.
  7. 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.
  8. Identity check. The API accepts the session only if the returned user has an identity for the same provider whose sub is the principal. Email, phone and user metadata are never used to match anyone.
  9. 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.
  10. Pending facts. Contact or bot permission received by webhook before this sign-in is applied. A failure here does not fail sign-in.
  11. Business calls. The Mini App receives only accessToken, refreshToken and expiresAt and uses the access token as a bearer token.

Refresh and sign-out​

Neither needs Telegram launch data or a new assertion:

Mini App callAPI 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 tokenlogout?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:

EndpointContent
GET /.well-known/openid-configurationissuer, 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.jsonPublic RSA keys only: the current key first, then retired keys from CHANNEL_AUTH_ASSERTION_RETIRED_PUBLIC_KEYS_JSON
GET/POST /oauth/authorizeAlways 400 unsupported_response_type
POST /oauth/tokenAlways 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_URL and 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.