
Native "Log in with Telegram" OAuth2+PKCE flow implementation offering app redirect plus web fallback, minimal API (configure/login/handle), and Telegram-signed OpenID Connect id_token.
A Kotlin Multiplatform SDK for Telegram's native "Log in with Telegram" flow. Write the login logic once in Kotlin for Android and iOS — no per-platform native SDK, no Swift/Kotlin bridge.
Community library implementing the same OAuth2 + PKCE flow as Telegram's official native SDKs. Not affiliated with or endorsed by Telegram.
id_token.ASWebAuthenticationSession (iOS).configure(), suspend login(), handle(). Typed results & errors.Telegram ships separate native SDKs for Android and iOS. In a KMP app you'd depend on both and shuttle results across the Kotlin/Swift boundary. This library puts the whole flow in commonMain; only a couple of tiny primitives are expect/actual.
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("app.univera.telegramlogin:telegram-login:0.1.0")
}
}
}Register both your Android and iOS apps with your bot so Telegram can verify them and issue each a secure redirect domain. Open @BotFather → Bot Settings → Login Widget — this is also where you get your bot's client_id.
For each registered app Telegram auto-generates a per-app domain https://app{appid}-login.tg.dev (the {appid} differs per app — Android vs iOS get different ones). The domain requires no manual registration.
Telegram verifies your app's cryptographic signature. Provide:
app.univera.android)../gradlew signingReport). With Play App Signing enabled, use the Play app-signing key's SHA-256, not the upload key — App Link verification checks the certificate the installed (Play-delivered) app ships with.Redirect URI — App Link (recommended): https://app{androidAppId}-login.tg.dev/tglogin (only your verified app can intercept it). Custom-scheme fallback: yourapp://telegram-login.
Provide:
app.univera.ios).ABCDE12345).Redirect URI — Universal Link (recommended): https://app{iosAppId}-login.tg.dev (prevents callback hijacking). Custom-scheme fallback: yourapp://tglogin.
Declare the App Link on the Activity that handles the callback, and let the app query the tg scheme:
<activity android:name=".MainActivity" android:launchMode="singleTask" android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="app{appid}-login.tg.dev"
android:pathPrefix="/tglogin" />
</intent-filter>
</activity>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="tg" />
</intent>
</queries>applinks:app{appid}-login.tg.dev
Info.plist → LSApplicationQueriesSchemes: add tg
During development iOS heavily caches the Universal-Links config. Append
?mode=developerto the Associated Domain (applinks:app{appid}-login.tg.dev?mode=developer) and toggle Settings → Developer → Associated Domains Development on a physical device. Remove it for release.
configure() is part of the common API, but the redirectUri is per-platform: BotFather issues a different app{appid}-login.tg.dev host for your Android app vs your iOS app, and Android's App Link uses the /tglogin path while iOS uses the bare host. Only clientId and scopes are the same on both — so supply the redirect via expect/actual:
// commonMain
import app.univera.telegramlogin.TelegramLogin
internal expect val telegramRedirectUri: String
fun initTelegramLogin() = TelegramLogin.configure(
clientId = "YOUR_BOT_CLIENT_ID", // your bot id — same on both platforms
redirectUri = telegramRedirectUri,
scopes = listOf("openid", "phone"),
// fallbackScheme = "yourapp", // optional: iOS < 17.4 web fallback
)// androidMain — Android app's host, WITH the /tglogin path
internal actual val telegramRedirectUri = "https://app<androidAppId>-login.tg.dev/tglogin"// iosMain — iOS app's host, NO path
internal actual val telegramRedirectUri = "https://app<iosAppId>-login.tg.dev"Call initTelegramLogin() once at startup (e.g. Application.onCreate, or a shared init invoked from each platform's entry point).
| Parameter | Description |
|---|---|
clientId |
Required. Your bot's client id — same on both platforms. |
redirectUri |
Required, per-platform. The app{appid}-login.tg.dev URL from @BotFather — host differs Android vs iOS; /tglogin on Android, bare host on iOS. |
scopes |
Required. e.g. ["openid", "phone"]. |
fallbackScheme |
Optional custom scheme for the iOS < 17.4 web fallback. |
login() opens Telegram and suspends until the redirect returns. Await it in a long-lived scope (e.g. a ViewModel):
when (val result = TelegramLogin.login(context)) { // context: TelegramAuthContext
is TelegramLoginResult.Success -> sendToBackend(result.idToken)
is TelegramLoginResult.Failure -> showError(result.error)
}Build the TelegramAuthContext per platform. In Compose Multiplatform a tiny expect/actual helper keeps the call site common:
// commonMain
@Composable expect fun rememberTelegramAuthContext(): TelegramAuthContext
// androidMain — applicationContext is enough (the SDK uses FLAG_ACTIVITY_NEW_TASK)
@Composable actual fun rememberTelegramAuthContext() =
remember { TelegramAuthContext(LocalContext.current.applicationContext) }
// iosMain
@Composable actual fun rememberTelegramAuthContext() = remember { TelegramAuthContext() }The OS delivers the redirect to a platform entry point; pass it to the SDK to resume login():
Android (Activity.onNewIntent):
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.data?.let { TelegramLogin.handle(it.toString()) }
}iOS (SwiftUI):
ContentView().onOpenURL { url in
TelegramLogin.shared.handle(callbackUrl: url.absoluteString)
}Success.idToken is a Telegram-signed OpenID Connect JWT. Always verify it server-side before trusting any claim:
https://oauth.telegram.org/.well-known/jwks.json
https://oauth.telegram.org
See Validating ID tokens.
When Telegram isn't installed, the SDK opens the hosted login:
handle().ASWebAuthenticationSession with the https callback — zero extra config.fallbackScheme to configure() (and register it), since ASWebAuthenticationSession can't intercept an https callback on older iOS. Without it, login() returns TelegramNotInstalled.sealed interface TelegramLoginResult {
data class Success(val idToken: String)
data class Failure(val error: TelegramLoginError)
}
sealed class TelegramLoginError { // all extend Exception
NotConfigured // configure() not called
TelegramNotInstalled // Telegram missing and no web fallback available
NoAuthorizationCode // redirect had no ?code
Cancelled // user dismissed the flow
Server(statusCode) // Telegram returned non-2xx
Network(reason) // transport failure
Unexpected(detail) // anything else
}Telegram shows the verified badge only when (1) the app is published in the store with the BotFather-registered package/bundle, and (2) login uses the *.tg.dev link (this SDK does). Debug / unpublished builds fall back to the unverified path — test the verified flow on a Play internal-testing track (iOS: TestFlight). This is decided by Telegram, not the SDK.
fallbackScheme.MIT.
A Kotlin Multiplatform SDK for Telegram's native "Log in with Telegram" flow. Write the login logic once in Kotlin for Android and iOS — no per-platform native SDK, no Swift/Kotlin bridge.
Community library implementing the same OAuth2 + PKCE flow as Telegram's official native SDKs. Not affiliated with or endorsed by Telegram.
id_token.ASWebAuthenticationSession (iOS).configure(), suspend login(), handle(). Typed results & errors.Telegram ships separate native SDKs for Android and iOS. In a KMP app you'd depend on both and shuttle results across the Kotlin/Swift boundary. This library puts the whole flow in commonMain; only a couple of tiny primitives are expect/actual.
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("app.univera.telegramlogin:telegram-login:0.1.0")
}
}
}Register both your Android and iOS apps with your bot so Telegram can verify them and issue each a secure redirect domain. Open @BotFather → Bot Settings → Login Widget — this is also where you get your bot's client_id.
For each registered app Telegram auto-generates a per-app domain https://app{appid}-login.tg.dev (the {appid} differs per app — Android vs iOS get different ones). The domain requires no manual registration.
Telegram verifies your app's cryptographic signature. Provide:
app.univera.android)../gradlew signingReport). With Play App Signing enabled, use the Play app-signing key's SHA-256, not the upload key — App Link verification checks the certificate the installed (Play-delivered) app ships with.Redirect URI — App Link (recommended): https://app{androidAppId}-login.tg.dev/tglogin (only your verified app can intercept it). Custom-scheme fallback: yourapp://telegram-login.
Provide:
app.univera.ios).ABCDE12345).Redirect URI — Universal Link (recommended): https://app{iosAppId}-login.tg.dev (prevents callback hijacking). Custom-scheme fallback: yourapp://tglogin.
Declare the App Link on the Activity that handles the callback, and let the app query the tg scheme:
<activity android:name=".MainActivity" android:launchMode="singleTask" android:exported="true">
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="app{appid}-login.tg.dev"
android:pathPrefix="/tglogin" />
</intent-filter>
</activity>
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="tg" />
</intent>
</queries>applinks:app{appid}-login.tg.dev
Info.plist → LSApplicationQueriesSchemes: add tg
During development iOS heavily caches the Universal-Links config. Append
?mode=developerto the Associated Domain (applinks:app{appid}-login.tg.dev?mode=developer) and toggle Settings → Developer → Associated Domains Development on a physical device. Remove it for release.
configure() is part of the common API, but the redirectUri is per-platform: BotFather issues a different app{appid}-login.tg.dev host for your Android app vs your iOS app, and Android's App Link uses the /tglogin path while iOS uses the bare host. Only clientId and scopes are the same on both — so supply the redirect via expect/actual:
// commonMain
import app.univera.telegramlogin.TelegramLogin
internal expect val telegramRedirectUri: String
fun initTelegramLogin() = TelegramLogin.configure(
clientId = "YOUR_BOT_CLIENT_ID", // your bot id — same on both platforms
redirectUri = telegramRedirectUri,
scopes = listOf("openid", "phone"),
// fallbackScheme = "yourapp", // optional: iOS < 17.4 web fallback
)// androidMain — Android app's host, WITH the /tglogin path
internal actual val telegramRedirectUri = "https://app<androidAppId>-login.tg.dev/tglogin"// iosMain — iOS app's host, NO path
internal actual val telegramRedirectUri = "https://app<iosAppId>-login.tg.dev"Call initTelegramLogin() once at startup (e.g. Application.onCreate, or a shared init invoked from each platform's entry point).
| Parameter | Description |
|---|---|
clientId |
Required. Your bot's client id — same on both platforms. |
redirectUri |
Required, per-platform. The app{appid}-login.tg.dev URL from @BotFather — host differs Android vs iOS; /tglogin on Android, bare host on iOS. |
scopes |
Required. e.g. ["openid", "phone"]. |
fallbackScheme |
Optional custom scheme for the iOS < 17.4 web fallback. |
login() opens Telegram and suspends until the redirect returns. Await it in a long-lived scope (e.g. a ViewModel):
when (val result = TelegramLogin.login(context)) { // context: TelegramAuthContext
is TelegramLoginResult.Success -> sendToBackend(result.idToken)
is TelegramLoginResult.Failure -> showError(result.error)
}Build the TelegramAuthContext per platform. In Compose Multiplatform a tiny expect/actual helper keeps the call site common:
// commonMain
@Composable expect fun rememberTelegramAuthContext(): TelegramAuthContext
// androidMain — applicationContext is enough (the SDK uses FLAG_ACTIVITY_NEW_TASK)
@Composable actual fun rememberTelegramAuthContext() =
remember { TelegramAuthContext(LocalContext.current.applicationContext) }
// iosMain
@Composable actual fun rememberTelegramAuthContext() = remember { TelegramAuthContext() }The OS delivers the redirect to a platform entry point; pass it to the SDK to resume login():
Android (Activity.onNewIntent):
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
intent.data?.let { TelegramLogin.handle(it.toString()) }
}iOS (SwiftUI):
ContentView().onOpenURL { url in
TelegramLogin.shared.handle(callbackUrl: url.absoluteString)
}Success.idToken is a Telegram-signed OpenID Connect JWT. Always verify it server-side before trusting any claim:
https://oauth.telegram.org/.well-known/jwks.json
https://oauth.telegram.org
See Validating ID tokens.
When Telegram isn't installed, the SDK opens the hosted login:
handle().ASWebAuthenticationSession with the https callback — zero extra config.fallbackScheme to configure() (and register it), since ASWebAuthenticationSession can't intercept an https callback on older iOS. Without it, login() returns TelegramNotInstalled.sealed interface TelegramLoginResult {
data class Success(val idToken: String)
data class Failure(val error: TelegramLoginError)
}
sealed class TelegramLoginError { // all extend Exception
NotConfigured // configure() not called
TelegramNotInstalled // Telegram missing and no web fallback available
NoAuthorizationCode // redirect had no ?code
Cancelled // user dismissed the flow
Server(statusCode) // Telegram returned non-2xx
Network(reason) // transport failure
Unexpected(detail) // anything else
}Telegram shows the verified badge only when (1) the app is published in the store with the BotFather-registered package/bundle, and (2) login uses the *.tg.dev link (this SDK does). Debug / unpublished builds fall back to the unverified path — test the verified flow on a Play internal-testing track (iOS: TestFlight). This is decided by Telegram, not the SDK.
fallbackScheme.MIT.