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.
- 🟣 One Kotlin API for Android + iOS — drop the two native SDKs and the bridging glue.
- 🔐 OAuth2 + PKCE (S256), exactly like the official SDKs — returns a Telegram-signed OpenID Connect
id_token. - 📱 Native app flow — opens the installed Telegram app via the cross-app link.
- 🌐 Web fallback when Telegram isn't installed — Custom Tabs (Android) /
ASWebAuthenticationSession(iOS). - 🧩 Tiny surface —
configure(),suspend login(),handle(). Typed results & errors. - 🪶 Light — Ktor + okio only. No Telegram binary, no GitHub Packages auth.
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.
- Kotlin 2.3.21+ (the library is built with 2.3.21 for broad consumer compatibility).
- Android minSdk 23+, iOS 15.0+ (web fallback auto-opens on iOS 17.4+ — see Web fallback).
- A Telegram bot registered with @BotFather.
// 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:
- Package Name — your application ID (e.g.
app.univera.android). - SHA-256 fingerprint — of the signing keystore (
./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:
- Bundle ID — your app's identifier (e.g.
app.univera.ios). - Team ID — your 10-character Apple Developer Team ID (e.g.
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>- Signing & Capabilities → Associated Domains:
applinks:app{appid}-login.tg.dev Info.plist→LSApplicationQueriesSchemes: addtg
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:
- JWKS:
https://oauth.telegram.org/.well-known/jwks.json - issuer:
https://oauth.telegram.org - audience: your bot's client id
See Validating ID tokens.
When Telegram isn't installed, the SDK opens the hosted login:
- Android: a Custom Tab; the redirect returns through your App Link into
handle(). - iOS 17.4+:
ASWebAuthenticationSessionwith thehttpscallback — zero extra config. - iOS < 17.4: pass a custom
fallbackSchemetoconfigure()(and register it), sinceASWebAuthenticationSessioncan't intercept anhttpscallback on older iOS. Without it,login()returnsTelegramNotInstalled.
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.
- If the process is killed while the user is in Telegram, the in-memory PKCE verifier is lost and the login can't complete on return (no crash — it just ends silently). Same as the official SDKs.
- iOS web fallback auto-opens on 17.4+; older iOS needs
fallbackScheme.
MIT.