
One shared API for LINE Login across native SDKs and LIFF-powered web; app-to-app and browser flows, distinct Cancel result, consistent error taxonomy, no duplicate session, optional compliant login button.
LINE Login for Kotlin Multiplatform. One shared API on Android, iOS and the web, with LINE's own SDKs underneath — the native ones on mobile, LIFF in the browser.
LineLogin.configure(LineLoginConfig(channelId = "1234567890"))
when (val result = LineLogin.login()) {
is LineLoginResult.Success -> println(result.profile?.displayName)
LineLoginResult.Cancelled -> Unit
is LineLoginResult.Failure -> println(result.message)
}That is the whole API on all three targets — including the app-to-app flow, the browser fallback when LINE is not installed, and token storage. Where a platform genuinely differs, the difference is documented rather than hidden: see Web, which redirects instead of opening a dialog.
INTERNET permission and
the Context bootstrap are all merged in for you.Cancelled is its own result, so backing out of
a login never reaches your error handling.lineloginkmp-compose implements LINE's
button design guidelines so you do not have to re-derive them from a PSD. It publishes for the
web too, so a Compose Multiplatform app keeps one button across all three targets.| Component | Requirement |
|---|---|
| Android | minSdk 24 — the floor LINE's own AAR declares |
| iOS | 15.0 — raised by LINE iOS SDK 5.17.0. LINE's docs still say 13.0; they are stale |
| Web |
Kotlin/Wasm (wasmJs, browser) — runs on LIFF, so the channel needs a LIFF app; see Web
|
| Kotlin | 2.4.0+ |
| Xcode | any recent version for the core library · 26+ if you use lineloginkmp-compose, which inherits Compose Multiplatform 1.11's requirement |
| LINE SDK | Android 5.13.0 (pulled in automatically) · iOS 5.17.0 (you add it via SPM) |
You also need a LINE Login channel from the LINE Developers Console, with your Android package name and signing-certificate SHA-1, and your iOS bundle identifier, registered on it — and the channel published. For the web, add a LIFF app to that same channel; Web walks through it.
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.yjyoon.lineloginkmp:lineloginkmp:1.1.0")
// Optional: a Compose Multiplatform login button that follows LINE's design
// guidelines. Skip it if you would rather build the login button yourself.
implementation("dev.yjyoon.lineloginkmp:lineloginkmp-compose:1.1.0")
}
}
}Your shared module's iOS framework must export this library, or nothing from it appears in the generated Objective-C header and your Swift code cannot see it:
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true // required — frameworks are dynamic by default
export("dev.yjyoon.lineloginkmp:lineloginkmp:1.1.0") // ← add this
}
}
sourceSets {
commonMain.dependencies {
api("dev.yjyoon.lineloginkmp:lineloginkmp:1.1.0") // `api`, so there is something to export
}
}
}File ▸ Add Package Dependencies… → https://github.com/line/line-sdk-ios-swift → choose the
LineSDKObjC product.
LineSDKObjC— notLineSDK. It is the same SDK behind an Objective-C facade, and it is the one Kotlin can call. Picking the wrong product fails at link time with a screenful ofUndefined symbols: _OBJC_CLASS_$__TtC11LineSDKObjC….CocoaPods is not supported: its podspec renames the module, which breaks the symbol names.
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>line3rdp.$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>lineauth2</string>
</array>The first is where LINE returns after a web login. The second lets the SDK detect the LINE app —
omit it and every user silently gets the slower browser flow, with nothing anywhere explaining why,
and isLineAppInstalled() answers false on every device.
Android needs no manifest changes at all.
// Once, at startup — App.onCreate on Android, your app's entry point on iOS, or any shared
// initialisation that runs before the first login.
LineLogin.configure(LineLoginConfig(channelId = "1234567890"))
suspend fun signIn() {
when (val result = LineLogin.login()) {
is LineLoginResult.Success -> {
val userId = result.profile?.userId
val idToken = result.idToken?.rawValue // send this to your backend
}
// The user changed their mind. Show nothing.
LineLoginResult.Cancelled -> Unit
is LineLoginResult.Failure -> when (result.code) {
LineLoginErrorCode.Network -> retryLater()
LineLoginErrorCode.Authentication -> reportMisconfiguration(result.message)
else -> showError(result.message)
}
}
}Asking for more, or fewer, permissions:
LineLogin.login(
LineLoginRequest(
scopes = setOf(LineScope.Profile, LineScope.OpenId, LineScope.Email),
nonce = nonceFromYourServer,
botPrompt = LineBotPrompt.Normal,
),
)Signing out, and checking whether anyone is signed in:
LineLogin.logout()
if (LineLogin.isLoggedIn()) { /* a token exists on this device */ }
val token = LineLogin.currentAccessToken()Whether this device has the LINE app, which decides the route a login takes — app-to-app, or the browser. For deciding what to show; login works either way:
if (LineLogin.isLineAppInstalled()) { /* … */ }Nothing to do. The application Context is picked up by an androidx.startup initialiser, so
configure works from shared code.
Two Android-only overloads exist for apps that strip
androidx.startup.InitializationProvider from their manifest, and take the Context directly:
LineLogin.configure(context, LineLoginConfig(channelId = "1234567890"))
if (LineLogin.isLineAppInstalled(context)) { /* … */ } // this one does not suspendlogin() must be called while your app is in the foreground — Android does not allow starting an
Activity from the background, and doing so returns a Failure saying exactly that.
One line, so the SDK sees the callback that finishes a browser-based login:
import Shared // your shared framework
ContentView()
.onOpenURL { url in
_ = LineLoginUrlHandler.shared.handle(url: url)
}A SwiftUI App is scene-based and UIKit delivers URLs to the scene, so .onOpenURL is the right
hook — AppDelegate.application(_:open:options:) is never called in an app shaped like that.
In a UIKit app, forward both entry points instead:
func application(_ app: UIApplication, open url: URL, options: […]) -> Bool {
LineLoginUrlHandler.shared.handle(url: url)
}
func application(_ app: UIApplication, continue userActivity: NSUserActivity, …) -> Bool {
guard let url = userActivity.webpageURL else { return false }
return LineLoginUrlHandler.shared.handle(url: url)
}The second one only matters when you configure universalLinkUrl, which is exactly what makes
forgetting it fail intermittently.
The browser target runs on LIFF, LINE's
official JavaScript SDK, and that choice is forced rather than taken: LINE's plain OAuth token
exchange requires the channel secret (client_secret is Required in the API reference, and
PKCE only adds a parameter beside it), and a secret shipped in a browser is a secret published.
LIFF is LINE's own login that completes without it.
Two one-time steps:
LineLoginRequest is ignored on this platform, because in LIFF
the scopes belong to that registration.LineLogin.configure(
LineLoginConfig(
channelId = "1234567890",
liffId = "1234567890-abcdefgh", // web only; Android and iOS ignore it
),
)The SDK itself loads from LINE's CDN the first time configure runs — nothing to bundle. An app
that already ships @line/liff wins: the loader steps aside whenever globalThis.liff exists.
login() is a full-page redirect here, not a dialog. With nobody signed in, the call navigates
to LINE and never returns — the page unloads underneath it. LINE redirects back, your app starts
fresh, and configure completes the login while initialising: by the time your first frame renders,
the session already exists.
That last part is why a web app has to check for a session at startup rather than waiting for a tap. Skip this and a user who has just logged in comes back to a page that still shows a login button, because nothing has asked:
// wherever your app starts — the same code works on every platform
LaunchedEffect(Unit) {
if (LineLogin.isLoggedIn()) {
showSignedIn(LineLogin.login()) // returns immediately, no UI, no redirect
}
}Then the button's own handler can be fire-and-forget: on web it navigates away, and on Android and iOS it returns a result as usual.
Two smaller differences, both also on the KDoc: logout() clears this browser only (LIFF has no
client-side revoke, so the grant survives until it expires), and currentAccessToken() asks LINE
for the expiry — one network round trip — because LIFF does not store one.
LINE requires login buttons to follow its button design guidelines — the colours, the divider, the padding, the isolation zone and the caption are all specified, and "using a non-designated color" is called out as a mistake. Read them before you draw your own.
lineloginkmp-compose implements them:
LineLoginButton(onClick = { scope.launch { handle(LineLogin.login()) } })That gives you LINE's own icon and caption, the exact palette including the hover and press overlays and the white disabled state, the divider between logo and caption, and geometry taken from LINE's button artwork. The caption follows the reader's language — 18 of them, in LINE's own wording.
LineLoginButton(
onClick = ::signIn,
enabled = !busy,
text = LineLoginButtonText.current().short, // "Log in" instead of "Log in with LINE"
height = 32.dp, // scales the whole button
)
LineLoginButton(onClick = ::signIn, text = null) // icon only, which the guidelines also allowScale through height: the icon, the divider, the corner radius, the padding and the caption size
are all derived from it, so the icon's aspect ratio and the required padding hold at any size. The
ratios come from measuring LINE's own 20/32/44 dp button images, and a rendered 44 dp button matches
that artwork to within a fraction of a dp.
LineLoginButtonColors and LineLoginButtonDefaults are public, so an app drawing its own button —
in Android Views or SwiftUI — can still take the exact values.
The LINE icon is bundled — the button needs no asset from you. It is the unmodified white icon from LINE's official template, so the mark is LINE's own rather than a lookalike, which the guidelines require.
That icon is a trademark of LY Corporation and is not covered by this project's Apache licence. Using this button means LINE's Usage Guidelines for the LINE Login Button apply to your app too. See NOTICE.
The isolation zone is the one rule the button cannot enforce from the inside: keep other content
LineLoginButtonDefaults.isolationZone() away from it.
| Type | What it is |
|---|---|
LineLogin |
The entry point: configure, isConfigured, login, logout, currentAccessToken, isLoggedIn
|
LineLoginConfig |
Channel ID, an optional iOS universal link, and the web target's LIFF app ID |
LineLoginRequest |
Per-login options: scopes, nonce, forceWebLogin, bot prompt |
LineScope |
Profile, OpenId, Email, or any scope LINE adds later |
LineLoginResult |
Success · Cancelled · Failure
|
LineLogoutResult |
Success · Failure
|
LineLoginErrorCode |
NotConfigured Network Server Authentication LineAppUnavailable LoginInProgress Internal
|
LineProfile |
userId, displayName, pictureUrl, statusMessage
|
LineIdToken |
The raw JWT plus locally decoded claims |
LineAccessToken |
Token value and expiry |
LineLoginUrlHandler |
iOS only. handle(url:)
|
From dev.yjyoon.lineloginkmp:lineloginkmp-compose:
| Type | What it is |
|---|---|
LineLoginButton |
The guideline-compliant button |
LineLoginButtonColors |
LINE's exact palette, including the state overlays |
LineLoginButtonDefaults |
Geometry and typography, all derived from the icon width |
LineLoginButtonText |
LINE's recommended captions in 18 languages, resolved by locale |
login() never throws. Every outcome is a value:
public sealed interface LineLoginResult {
public class Success(
public val accessToken: LineAccessToken,
public val profile: LineProfile?, // non-null if `profile` was granted
public val idToken: LineIdToken?, // non-null if `openid` was granted
public val friendshipStatusChanged: Boolean,
public val nonce: String?,
) : LineLoginResult
public data object Cancelled : LineLoginResult
public class Failure(
public val code: LineLoginErrorCode,
public val message: String,
public val rawCode: String?, // the native SDK's own code, verbatim
public val rawMessage: String?,
) : LineLoginResult
}The only exception that ever escapes is CancellationException, when your own coroutine is
cancelled. iOS dismisses the LINE screen with it. Android closes this library's own invisible
Activity but cannot dismiss LINE's screen once it is on top — and if the user finishes that
login anyway, LINE's SDK stores the token even though the call reported nothing, so
isLoggedIn() can be true after a cancelled login.
Send result.idToken.rawValue, never the user ID and never the access token. The ID token is
the only part of a login result LINE signs, so it is the only part your backend can verify — against
https://api.line.me/oauth2/v2.1/certs, checking the signature, aud, iss, exp, and the nonce
you issued. A client-supplied user ID is just a string anyone can send.
The claims decoded on LineIdToken are not signature-verified. They are there so you can greet
the user immediately, and for nothing else.
What the library merges into your manifest:
LineLoginProxyActivity — a headless, translucent Activity that owns the SDK's
startActivityForResult contract, because shared code has no Activity of its own.androidx.startup registration for the Context bootstrap.android.permission.INTERNET.The LINE AAR itself adds its two auth Activities, an OpenChat Activity, and the Android 11+
<queries> entry for jp.naver.line.android. It also ships its own ProGuard rules, which AGP
applies to your build — including two app-wide ones (-keepattributes *Annotation* and a keep for
every Parcelable.Creator). That is LINE's doing, not this library's, but it is worth knowing about.
isStatic = true). A dynamic one has to resolve LineSDKObjC's
symbols while Kotlin links, long before Xcode has fetched the Swift package.LineSDKObjC in Xcode and not in Gradle.PrivacyInfo.xcprivacy, so it needs no privacy-manifest work from you.Keyed by what you actually see.
| Symptom | Cause |
|---|---|
cannot find 'LineLoginUrlHandler' in scope |
Missing export("dev.yjyoon.lineloginkmp:lineloginkmp:…") in your framework block, or implementation instead of api. |
Undefined symbols: _OBJC_CLASS_$__TtC11LineSDKObjC… |
Your framework is dynamic (set isStatic = true — it is not the default), the LineSDKObjC SPM product is not added to the app target, or the LineSDK product was added instead of it. |
| Login always opens the browser, never the LINE app |
lineauth2 is missing from LSApplicationQueriesSchemes. Nothing errors — canOpenURL just returns false. |
| The browser finishes the login and nothing happens |
.onOpenURL is not wired up on iOS, or line3rdp.$(PRODUCT_BUNDLE_IDENTIFIER) is missing from CFBundleURLTypes. |
Failure(Authentication, …) on every attempt |
The console does not match this build: wrong package name, an unregistered signing SHA-1, a different bundle ID — or the channel is not published. |
Failure(Internal, "The LINE login screen did not start…") |
login() was called while the app was in the background on Android. |
Unknown iOS simulator arch: 'x86_64' |
An Intel simulator slice was requested for a project without an iosX64 target. Add EXCLUDED_ARCHS[sdk=iphonesimulator*] = x86_64. |
Failure(NotConfigured, …) on Android only |
Your app removed androidx.startup.InitializationProvider. Use LineLogin.configure(context, config). |
Web: every call fails with NotConfigured mentioning liffId
|
The browser login runs on LIFF, and LineLoginConfig.liffId was not set. Add a LIFF app to the channel in the console and pass its ID — see Web. |
| Web: the button's caption renders as boxes (□□□) | Compose for Web ships no CJK glyphs, so Japanese and Korean captions need a font your app registers — the same as any other CJK text in a Compose wasm app. Register one, or pass an ASCII text to LineLoginButton. |
Web: login() never returns |
That is the design, not a hang: in a browser it is a full-page redirect, and the page unloads before there is anything to return. See Web for the shape a web login takes. |
Why cinterop against LineSDKObjC, and not a Swift bridge.
LINE's iOS SDK is pure Swift, which Kotlin/Native cannot import — interop goes through Objective-C
only. The usual workaround is to declare a protocol in Kotlin and implement it in Swift inside the
consumer's app, which is both ~90 lines of everybody's boilerplate and impossible to ship
pre-built (Kotlin prefixes exported symbols with your framework's name). LINE also publishes
LineSDKObjC, an @objc facade over the same SDK, and Kotlin/Native binds to that directly. The
cost is that consumers must add that specific SPM product; the benefit is that Kotlin owns the whole
flow — including LoginProcess.stop() on cancellation, which the bridge approach cannot do
correctly.
Why no refresh token. Both SDKs own token rotation: iOS marks the property unavailable and
Android hides it behind an auto-refreshing proxy. Exposing it would create a second, stale copy of
state something else is already managing.
Why granted scopes are not reported. iOS cannot report them faithfully — its Objective-C layer
exposes permissions as opaque objects with no readable value and no equality. Read the data instead:
profile != null, idToken != null, idToken.email != null.
Why LineLogin is an object. LoginManager on iOS is a process-wide singleton whose setup is
one-shot and asserts on a second call. An API that let you build several clients would be lying.
sample/ is a Compose Multiplatform app running on Android, iOS and the web. Put your channel ID in
SampleConfig.kt,
then:
./gradlew :sample:composeApp:installDebug # Android
open sample/iosApp/iosApp.xcodeproj # iOSGenerated with Dokka and published to https://yjyoon-dev.github.io/line-login-kmp/ on every release.
Issues and pull requests are welcome — see CONTRIBUTING.md for how the project is laid out, which parts look wrong but are not, and what CI checks.
Copyright 2026 yjyoon
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
LINE, and the LINE icon bundled in lineloginkmp-compose, are trademarks of LY Corporation. The
icon is included under LINE's
Usage Guidelines for the LINE Login Button
and is not covered by the Apache licence above — see NOTICE. This is an independent
open-source project and is not affiliated with, endorsed by, or sponsored by LY Corporation.
LINE Login for Kotlin Multiplatform. One shared API on Android, iOS and the web, with LINE's own SDKs underneath — the native ones on mobile, LIFF in the browser.
LineLogin.configure(LineLoginConfig(channelId = "1234567890"))
when (val result = LineLogin.login()) {
is LineLoginResult.Success -> println(result.profile?.displayName)
LineLoginResult.Cancelled -> Unit
is LineLoginResult.Failure -> println(result.message)
}That is the whole API on all three targets — including the app-to-app flow, the browser fallback when LINE is not installed, and token storage. Where a platform genuinely differs, the difference is documented rather than hidden: see Web, which redirects instead of opening a dialog.
INTERNET permission and
the Context bootstrap are all merged in for you.Cancelled is its own result, so backing out of
a login never reaches your error handling.lineloginkmp-compose implements LINE's
button design guidelines so you do not have to re-derive them from a PSD. It publishes for the
web too, so a Compose Multiplatform app keeps one button across all three targets.| Component | Requirement |
|---|---|
| Android | minSdk 24 — the floor LINE's own AAR declares |
| iOS | 15.0 — raised by LINE iOS SDK 5.17.0. LINE's docs still say 13.0; they are stale |
| Web |
Kotlin/Wasm (wasmJs, browser) — runs on LIFF, so the channel needs a LIFF app; see Web
|
| Kotlin | 2.4.0+ |
| Xcode | any recent version for the core library · 26+ if you use lineloginkmp-compose, which inherits Compose Multiplatform 1.11's requirement |
| LINE SDK | Android 5.13.0 (pulled in automatically) · iOS 5.17.0 (you add it via SPM) |
You also need a LINE Login channel from the LINE Developers Console, with your Android package name and signing-certificate SHA-1, and your iOS bundle identifier, registered on it — and the channel published. For the web, add a LIFF app to that same channel; Web walks through it.
// shared/build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation("dev.yjyoon.lineloginkmp:lineloginkmp:1.1.0")
// Optional: a Compose Multiplatform login button that follows LINE's design
// guidelines. Skip it if you would rather build the login button yourself.
implementation("dev.yjyoon.lineloginkmp:lineloginkmp-compose:1.1.0")
}
}
}Your shared module's iOS framework must export this library, or nothing from it appears in the generated Objective-C header and your Swift code cannot see it:
kotlin {
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Shared"
isStatic = true // required — frameworks are dynamic by default
export("dev.yjyoon.lineloginkmp:lineloginkmp:1.1.0") // ← add this
}
}
sourceSets {
commonMain.dependencies {
api("dev.yjyoon.lineloginkmp:lineloginkmp:1.1.0") // `api`, so there is something to export
}
}
}File ▸ Add Package Dependencies… → https://github.com/line/line-sdk-ios-swift → choose the
LineSDKObjC product.
LineSDKObjC— notLineSDK. It is the same SDK behind an Objective-C facade, and it is the one Kotlin can call. Picking the wrong product fails at link time with a screenful ofUndefined symbols: _OBJC_CLASS_$__TtC11LineSDKObjC….CocoaPods is not supported: its podspec renames the module, which breaks the symbol names.
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>line3rdp.$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>lineauth2</string>
</array>The first is where LINE returns after a web login. The second lets the SDK detect the LINE app —
omit it and every user silently gets the slower browser flow, with nothing anywhere explaining why,
and isLineAppInstalled() answers false on every device.
Android needs no manifest changes at all.
// Once, at startup — App.onCreate on Android, your app's entry point on iOS, or any shared
// initialisation that runs before the first login.
LineLogin.configure(LineLoginConfig(channelId = "1234567890"))
suspend fun signIn() {
when (val result = LineLogin.login()) {
is LineLoginResult.Success -> {
val userId = result.profile?.userId
val idToken = result.idToken?.rawValue // send this to your backend
}
// The user changed their mind. Show nothing.
LineLoginResult.Cancelled -> Unit
is LineLoginResult.Failure -> when (result.code) {
LineLoginErrorCode.Network -> retryLater()
LineLoginErrorCode.Authentication -> reportMisconfiguration(result.message)
else -> showError(result.message)
}
}
}Asking for more, or fewer, permissions:
LineLogin.login(
LineLoginRequest(
scopes = setOf(LineScope.Profile, LineScope.OpenId, LineScope.Email),
nonce = nonceFromYourServer,
botPrompt = LineBotPrompt.Normal,
),
)Signing out, and checking whether anyone is signed in:
LineLogin.logout()
if (LineLogin.isLoggedIn()) { /* a token exists on this device */ }
val token = LineLogin.currentAccessToken()Whether this device has the LINE app, which decides the route a login takes — app-to-app, or the browser. For deciding what to show; login works either way:
if (LineLogin.isLineAppInstalled()) { /* … */ }Nothing to do. The application Context is picked up by an androidx.startup initialiser, so
configure works from shared code.
Two Android-only overloads exist for apps that strip
androidx.startup.InitializationProvider from their manifest, and take the Context directly:
LineLogin.configure(context, LineLoginConfig(channelId = "1234567890"))
if (LineLogin.isLineAppInstalled(context)) { /* … */ } // this one does not suspendlogin() must be called while your app is in the foreground — Android does not allow starting an
Activity from the background, and doing so returns a Failure saying exactly that.
One line, so the SDK sees the callback that finishes a browser-based login:
import Shared // your shared framework
ContentView()
.onOpenURL { url in
_ = LineLoginUrlHandler.shared.handle(url: url)
}A SwiftUI App is scene-based and UIKit delivers URLs to the scene, so .onOpenURL is the right
hook — AppDelegate.application(_:open:options:) is never called in an app shaped like that.
In a UIKit app, forward both entry points instead:
func application(_ app: UIApplication, open url: URL, options: […]) -> Bool {
LineLoginUrlHandler.shared.handle(url: url)
}
func application(_ app: UIApplication, continue userActivity: NSUserActivity, …) -> Bool {
guard let url = userActivity.webpageURL else { return false }
return LineLoginUrlHandler.shared.handle(url: url)
}The second one only matters when you configure universalLinkUrl, which is exactly what makes
forgetting it fail intermittently.
The browser target runs on LIFF, LINE's
official JavaScript SDK, and that choice is forced rather than taken: LINE's plain OAuth token
exchange requires the channel secret (client_secret is Required in the API reference, and
PKCE only adds a parameter beside it), and a secret shipped in a browser is a secret published.
LIFF is LINE's own login that completes without it.
Two one-time steps:
LineLoginRequest is ignored on this platform, because in LIFF
the scopes belong to that registration.LineLogin.configure(
LineLoginConfig(
channelId = "1234567890",
liffId = "1234567890-abcdefgh", // web only; Android and iOS ignore it
),
)The SDK itself loads from LINE's CDN the first time configure runs — nothing to bundle. An app
that already ships @line/liff wins: the loader steps aside whenever globalThis.liff exists.
login() is a full-page redirect here, not a dialog. With nobody signed in, the call navigates
to LINE and never returns — the page unloads underneath it. LINE redirects back, your app starts
fresh, and configure completes the login while initialising: by the time your first frame renders,
the session already exists.
That last part is why a web app has to check for a session at startup rather than waiting for a tap. Skip this and a user who has just logged in comes back to a page that still shows a login button, because nothing has asked:
// wherever your app starts — the same code works on every platform
LaunchedEffect(Unit) {
if (LineLogin.isLoggedIn()) {
showSignedIn(LineLogin.login()) // returns immediately, no UI, no redirect
}
}Then the button's own handler can be fire-and-forget: on web it navigates away, and on Android and iOS it returns a result as usual.
Two smaller differences, both also on the KDoc: logout() clears this browser only (LIFF has no
client-side revoke, so the grant survives until it expires), and currentAccessToken() asks LINE
for the expiry — one network round trip — because LIFF does not store one.
LINE requires login buttons to follow its button design guidelines — the colours, the divider, the padding, the isolation zone and the caption are all specified, and "using a non-designated color" is called out as a mistake. Read them before you draw your own.
lineloginkmp-compose implements them:
LineLoginButton(onClick = { scope.launch { handle(LineLogin.login()) } })That gives you LINE's own icon and caption, the exact palette including the hover and press overlays and the white disabled state, the divider between logo and caption, and geometry taken from LINE's button artwork. The caption follows the reader's language — 18 of them, in LINE's own wording.
LineLoginButton(
onClick = ::signIn,
enabled = !busy,
text = LineLoginButtonText.current().short, // "Log in" instead of "Log in with LINE"
height = 32.dp, // scales the whole button
)
LineLoginButton(onClick = ::signIn, text = null) // icon only, which the guidelines also allowScale through height: the icon, the divider, the corner radius, the padding and the caption size
are all derived from it, so the icon's aspect ratio and the required padding hold at any size. The
ratios come from measuring LINE's own 20/32/44 dp button images, and a rendered 44 dp button matches
that artwork to within a fraction of a dp.
LineLoginButtonColors and LineLoginButtonDefaults are public, so an app drawing its own button —
in Android Views or SwiftUI — can still take the exact values.
The LINE icon is bundled — the button needs no asset from you. It is the unmodified white icon from LINE's official template, so the mark is LINE's own rather than a lookalike, which the guidelines require.
That icon is a trademark of LY Corporation and is not covered by this project's Apache licence. Using this button means LINE's Usage Guidelines for the LINE Login Button apply to your app too. See NOTICE.
The isolation zone is the one rule the button cannot enforce from the inside: keep other content
LineLoginButtonDefaults.isolationZone() away from it.
| Type | What it is |
|---|---|
LineLogin |
The entry point: configure, isConfigured, login, logout, currentAccessToken, isLoggedIn
|
LineLoginConfig |
Channel ID, an optional iOS universal link, and the web target's LIFF app ID |
LineLoginRequest |
Per-login options: scopes, nonce, forceWebLogin, bot prompt |
LineScope |
Profile, OpenId, Email, or any scope LINE adds later |
LineLoginResult |
Success · Cancelled · Failure
|
LineLogoutResult |
Success · Failure
|
LineLoginErrorCode |
NotConfigured Network Server Authentication LineAppUnavailable LoginInProgress Internal
|
LineProfile |
userId, displayName, pictureUrl, statusMessage
|
LineIdToken |
The raw JWT plus locally decoded claims |
LineAccessToken |
Token value and expiry |
LineLoginUrlHandler |
iOS only. handle(url:)
|
From dev.yjyoon.lineloginkmp:lineloginkmp-compose:
| Type | What it is |
|---|---|
LineLoginButton |
The guideline-compliant button |
LineLoginButtonColors |
LINE's exact palette, including the state overlays |
LineLoginButtonDefaults |
Geometry and typography, all derived from the icon width |
LineLoginButtonText |
LINE's recommended captions in 18 languages, resolved by locale |
login() never throws. Every outcome is a value:
public sealed interface LineLoginResult {
public class Success(
public val accessToken: LineAccessToken,
public val profile: LineProfile?, // non-null if `profile` was granted
public val idToken: LineIdToken?, // non-null if `openid` was granted
public val friendshipStatusChanged: Boolean,
public val nonce: String?,
) : LineLoginResult
public data object Cancelled : LineLoginResult
public class Failure(
public val code: LineLoginErrorCode,
public val message: String,
public val rawCode: String?, // the native SDK's own code, verbatim
public val rawMessage: String?,
) : LineLoginResult
}The only exception that ever escapes is CancellationException, when your own coroutine is
cancelled. iOS dismisses the LINE screen with it. Android closes this library's own invisible
Activity but cannot dismiss LINE's screen once it is on top — and if the user finishes that
login anyway, LINE's SDK stores the token even though the call reported nothing, so
isLoggedIn() can be true after a cancelled login.
Send result.idToken.rawValue, never the user ID and never the access token. The ID token is
the only part of a login result LINE signs, so it is the only part your backend can verify — against
https://api.line.me/oauth2/v2.1/certs, checking the signature, aud, iss, exp, and the nonce
you issued. A client-supplied user ID is just a string anyone can send.
The claims decoded on LineIdToken are not signature-verified. They are there so you can greet
the user immediately, and for nothing else.
What the library merges into your manifest:
LineLoginProxyActivity — a headless, translucent Activity that owns the SDK's
startActivityForResult contract, because shared code has no Activity of its own.androidx.startup registration for the Context bootstrap.android.permission.INTERNET.The LINE AAR itself adds its two auth Activities, an OpenChat Activity, and the Android 11+
<queries> entry for jp.naver.line.android. It also ships its own ProGuard rules, which AGP
applies to your build — including two app-wide ones (-keepattributes *Annotation* and a keep for
every Parcelable.Creator). That is LINE's doing, not this library's, but it is worth knowing about.
isStatic = true). A dynamic one has to resolve LineSDKObjC's
symbols while Kotlin links, long before Xcode has fetched the Swift package.LineSDKObjC in Xcode and not in Gradle.PrivacyInfo.xcprivacy, so it needs no privacy-manifest work from you.Keyed by what you actually see.
| Symptom | Cause |
|---|---|
cannot find 'LineLoginUrlHandler' in scope |
Missing export("dev.yjyoon.lineloginkmp:lineloginkmp:…") in your framework block, or implementation instead of api. |
Undefined symbols: _OBJC_CLASS_$__TtC11LineSDKObjC… |
Your framework is dynamic (set isStatic = true — it is not the default), the LineSDKObjC SPM product is not added to the app target, or the LineSDK product was added instead of it. |
| Login always opens the browser, never the LINE app |
lineauth2 is missing from LSApplicationQueriesSchemes. Nothing errors — canOpenURL just returns false. |
| The browser finishes the login and nothing happens |
.onOpenURL is not wired up on iOS, or line3rdp.$(PRODUCT_BUNDLE_IDENTIFIER) is missing from CFBundleURLTypes. |
Failure(Authentication, …) on every attempt |
The console does not match this build: wrong package name, an unregistered signing SHA-1, a different bundle ID — or the channel is not published. |
Failure(Internal, "The LINE login screen did not start…") |
login() was called while the app was in the background on Android. |
Unknown iOS simulator arch: 'x86_64' |
An Intel simulator slice was requested for a project without an iosX64 target. Add EXCLUDED_ARCHS[sdk=iphonesimulator*] = x86_64. |
Failure(NotConfigured, …) on Android only |
Your app removed androidx.startup.InitializationProvider. Use LineLogin.configure(context, config). |
Web: every call fails with NotConfigured mentioning liffId
|
The browser login runs on LIFF, and LineLoginConfig.liffId was not set. Add a LIFF app to the channel in the console and pass its ID — see Web. |
| Web: the button's caption renders as boxes (□□□) | Compose for Web ships no CJK glyphs, so Japanese and Korean captions need a font your app registers — the same as any other CJK text in a Compose wasm app. Register one, or pass an ASCII text to LineLoginButton. |
Web: login() never returns |
That is the design, not a hang: in a browser it is a full-page redirect, and the page unloads before there is anything to return. See Web for the shape a web login takes. |
Why cinterop against LineSDKObjC, and not a Swift bridge.
LINE's iOS SDK is pure Swift, which Kotlin/Native cannot import — interop goes through Objective-C
only. The usual workaround is to declare a protocol in Kotlin and implement it in Swift inside the
consumer's app, which is both ~90 lines of everybody's boilerplate and impossible to ship
pre-built (Kotlin prefixes exported symbols with your framework's name). LINE also publishes
LineSDKObjC, an @objc facade over the same SDK, and Kotlin/Native binds to that directly. The
cost is that consumers must add that specific SPM product; the benefit is that Kotlin owns the whole
flow — including LoginProcess.stop() on cancellation, which the bridge approach cannot do
correctly.
Why no refresh token. Both SDKs own token rotation: iOS marks the property unavailable and
Android hides it behind an auto-refreshing proxy. Exposing it would create a second, stale copy of
state something else is already managing.
Why granted scopes are not reported. iOS cannot report them faithfully — its Objective-C layer
exposes permissions as opaque objects with no readable value and no equality. Read the data instead:
profile != null, idToken != null, idToken.email != null.
Why LineLogin is an object. LoginManager on iOS is a process-wide singleton whose setup is
one-shot and asserts on a second call. An API that let you build several clients would be lying.
sample/ is a Compose Multiplatform app running on Android, iOS and the web. Put your channel ID in
SampleConfig.kt,
then:
./gradlew :sample:composeApp:installDebug # Android
open sample/iosApp/iosApp.xcodeproj # iOSGenerated with Dokka and published to https://yjyoon-dev.github.io/line-login-kmp/ on every release.
Issues and pull requests are welcome — see CONTRIBUTING.md for how the project is laid out, which parts look wrong but are not, and what CI checks.
Copyright 2026 yjyoon
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
LINE, and the LINE icon bundled in lineloginkmp-compose, are trademarks of LY Corporation. The
icon is included under LINE's
Usage Guidelines for the LINE Login Button
and is not covered by the Apache licence above — see NOTICE. This is an independent
open-source project and is not affiliated with, endorsed by, or sponsored by LY Corporation.