passkeys-kmp
0.2.0indexedUnified passkeys API offering create/authenticate flows backed by native authenticators, declarative UI integration, browser handoff, libfido2 support, and a server module for WebAuthn verification.
Unified passkeys API offering create/authenticate flows backed by native authenticators, declarative UI integration, browser handoff, libfido2 support, and a server module for WebAuthn verification.
Simple. Secure. Passwordless.
One common passkeys API for Kotlin Multiplatform, backed by real native authenticators on Android, iOS, macOS, Windows, Linux, browser (Wasm), and JVM/Compose Desktop.
implementation("io.github.androidpoet:passkeys:0.2.0") // core SDK
implementation("io.github.androidpoet:passkeys-compose:0.2.0") // rememberPasskeyClient() (Compose MP)
implementation("io.github.androidpoet:passkeys-server:0.2.0") // Ktor Relying Party (JVM server)
One call site, every platform — create / authenticate return a PasskeyResult:
val passkeys = rememberPasskeyClient() // resolves the platform client + its UI anchor
when (val result = passkeys.create(registrationOptionsJson)) { // or .authenticate(...)
is PasskeyResult.Success -> sendToBackend(result.value.rawJson) // verify on your server
is PasskeyResult.Failure -> handle(result.error.code, result.error.message)
}
Same shared create call, each platform's own native authenticator UI:
A passkey is bound to your domain, so each platform needs proof you own it. Host these
two files under https://your-domain.com/.well-known/ (web just needs HTTPS):
// assetlinks.json (Android)
[
// apple-app-site-association (iOS + macOS — no extension, served as application/json)
{ "webcredentials": { "apps": ["TEAMID.com.your.app"] } }
Then add the Associated Domains entitlement to your Apple target:
<key>com.apple.developer.associated-domains</key>
<array><string>webcredentials:your-domain.com</string></array>
The SDK only runs the device ceremony — a passkey is trustworthy . Your server generates a fresh into the options JSON, you pass that to / , then POST back to verify and store it. Use a maintained WebAuthn server library — , , , or — to check the challenge, origin, RP ID, signature, and sign-count. carries every field they expect. On a Kotlin/JVM backend you can use this project's own module.
On macOS, JvmPasskeyClient drives the real Touch ID ceremony via a bundled native backend
(, a Swift + JNI shim over AuthenticationServices). The ceremony
carrying the restricted
entitlement with an embedded provisioning profile — a bare will not launch. On
Windows/Linux (or if the native backend can't load) the client fails loud; use browser handoff:
largeBlob: iOS 17+ / macOS 14+ — prf: iOS 18+ / macOS 15+PasskeyException.Unsupported before any UIrawJson.clientExtensionResultsNo platform/biometric authenticator, so LinuxPasskeyClient supports roaming USB/NFC security
keys via libfido2. Requires libfido2-dev / libfido2-devel and udev rules granting non-root
access. Platform and phone/hybrid passkeys fail with a typed PasskeyException.
For a Kotlin/JVM backend, passkeys-server is the matching server half. It wraps
java-webauthn-server behind a small,
explicit API and mints/verifies exactly the WebAuthn JSON the clients above produce.
implementation("io.github.androidpoet:passkeys-server:<version>")
val relyingParty = PasskeyRelyingParty(
config = PasskeyServerConfig("example.com", "Example", setOf("https://example.com")),
credentials = InMemoryPasskeyCredentialStore(), // bring your own database
challenges = InMemoryPasskeyChallengeStore(), // bring your own short-TTL store
)
routing {
passkeyRoutes(relyingParty) // POST /passkeys/{register,login}/{begin,finish}
}
Each ceremony is two calls — a begin that returns the options the client passes to
create / authenticate, and a finish that verifies the client's . Storage is
bring-your-own via / ; the in-memory
implementations are for demos and tests. A runnable demo with a browser test page lives in
— .
:sample:composeApp is one Compose Multiplatform app — the whole UI lives in commonMain,
each entry point is just App(). Supply your own domain via a -P flag:
./gradlew :sample:composeApp:installDebug -PpasskeysSampleRpId=your-domain.com # Android
./gradlew :sample:composeApp:run -PpasskeysSampleRpId=your-domain.com # macOS desktop
…or, so you don't repeat it every build, put it in local.properties (gitignored, keeps your
domain private):
passkeysSampleRpId=your-domain.com
passkeysSampleBundleId=com.your.app
A browser demo lives in :sample:web.
./gradlew :passkeys:allTests spotlessCheck detekt apiCheck
./gradlew :passkeys:assemble :passkeys:publishToMavenLocal
Issues and PRs are welcome. Before opening a PR, run the gates:
./gradlew spotlessApply detekt apiCheck
apiCheck guards the public API — if you change it intentionally, regenerate the
dump with ./gradlew apiDump and commit it.
Give the repo a ⭐ — it helps others discover it.
Support it by joining stargazers for this repository. :star:
Also, follow me on GitHub for my next creations! 🤩
MIT License
Copyright (c) 2026 Ranbir Singh
See LICENSE for the full text.
| Platform | Authenticator | Anchor (auto via Compose) | One-time setup |
|---|
| Android (API 28+) | Fingerprint / face / PIN | Activity | assetlinks.json |
| iOS 16+ | Face ID / Touch ID | UIWindow | entitlement + AASA |
| macOS 13+ | Touch ID | NSWindow | entitlement + AASA |
| JVM / Compose Desktop | Touch ID (macOS) | window handle | signed .app + entitlement |
| Browser (Wasm) | Platform / security key | — | HTTPS |
| Windows 10 1903+ | Windows Hello / security key | HWND | — |
| Linux | Roaming USB/NFC key only | — | libfido2 + udev rules |
| Android — Credential Manager | macOS — Touch ID | Browser (Wasm) |
|---|---|---|
![]() | ![]() | ![]() |
challengecreateauthenticateresult.value.rawJsonrawJsonlibPasskeysNative.dylib.appcom.apple.developer.associated-domainsjava -jarPasskeyBrowserHandoff.open("https://your-rp.example/passkey/sign-in")
rawJsonPasskeyCredentialStorePasskeyChallengeStore./gradlew :sample:server:runSurfaced from shared tags and platforms — no rankings paid for.