
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)
}| 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 |
Same shared create call, each platform's own native authenticator UI:
| Android — Credential Manager | macOS — Touch ID | Browser (Wasm) |
|---|---|---|
![]() |
![]() |
![]() |
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):
// 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 only after your backend
verifies it. Your server generates a fresh challenge into the options JSON, you pass that
to create / authenticate, then POST result.value.rawJson back to verify and store it.
Use a maintained WebAuthn server library — java-webauthn-server,
webauthn4j, SimpleWebAuthn,
or py_webauthn — to check the challenge, origin,
RP ID, signature, and sign-count. rawJson carries every field they expect. On a Kotlin/JVM
backend you can use this project's own passkeys-server module.
On macOS, JvmPasskeyClient drives the real Touch ID ceremony via a bundled native backend
(libPasskeysNative.dylib, a Swift + JNI shim over AuthenticationServices). The ceremony
only runs from a signed .app carrying the restricted com.apple.developer.associated-domains
entitlement with an embedded provisioning profile — a bare java -jar will not launch. On
Windows/Linux (or if the native backend can't load) the client fails loud; use browser handoff:
PasskeyBrowserHandoff.open("https://your-rp.example/passkey/sign-in")largeBlob: iOS 17+ / macOS 14+ — prf: iOS 18+ / macOS 15+PasskeyException.Unsupported before any UIrawJson.clientExtensionResults
No 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 rawJson. Storage is
bring-your-own via PasskeyCredentialStore / PasskeyChallengeStore; the in-memory
implementations are for demos and tests. A runnable demo with a browser test page lives in
:sample:server — ./gradlew :sample:server:run.
: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.appA browser demo lives in :sample:web.
./gradlew :passkeys:allTests spotlessCheck detekt apiCheck
./gradlew :passkeys:assemble :passkeys:publishToMavenLocalIssues and PRs are welcome. Before opening a PR, run the gates:
./gradlew spotlessApply detekt apiCheckapiCheck 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.
MIT License
Copyright (c) 2026 Ranbir Singh
See LICENSE for the full text.
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)
}| 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 |
Same shared create call, each platform's own native authenticator UI:
| Android — Credential Manager | macOS — Touch ID | Browser (Wasm) |
|---|---|---|
![]() |
![]() |
![]() |
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)
[{ "relation": ["delegate_permission/common.get_login_creds"],
"target": { "namespace": "android_app", "package_name": "com.your.app",
"sha256_cert_fingerprints": ["YOUR:APP:SIGNING:SHA256"] } }]// 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 only after your backend
verifies it. Your server generates a fresh challenge into the options JSON, you pass that
to create / authenticate, then POST result.value.rawJson back to verify and store it.
Use a maintained WebAuthn server library — java-webauthn-server,
webauthn4j, SimpleWebAuthn,
or py_webauthn — to check the challenge, origin,
RP ID, signature, and sign-count. rawJson carries every field they expect. On a Kotlin/JVM
backend you can use this project's own passkeys-server module.
On macOS, JvmPasskeyClient drives the real Touch ID ceremony via a bundled native backend
(libPasskeysNative.dylib, a Swift + JNI shim over AuthenticationServices). The ceremony
only runs from a signed .app carrying the restricted com.apple.developer.associated-domains
entitlement with an embedded provisioning profile — a bare java -jar will not launch. On
Windows/Linux (or if the native backend can't load) the client fails loud; use browser handoff:
PasskeyBrowserHandoff.open("https://your-rp.example/passkey/sign-in")largeBlob: iOS 17+ / macOS 14+ — prf: iOS 18+ / macOS 15+PasskeyException.Unsupported before any UIrawJson.clientExtensionResults
No 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 rawJson. Storage is
bring-your-own via PasskeyCredentialStore / PasskeyChallengeStore; the in-memory
implementations are for demos and tests. A runnable demo with a browser test page lives in
:sample:server — ./gradlew :sample:server:run.
: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.appA browser demo lives in :sample:web.
./gradlew :passkeys:allTests spotlessCheck detekt apiCheck
./gradlew :passkeys:assemble :passkeys:publishToMavenLocalIssues and PRs are welcome. Before opening a PR, run the gates:
./gradlew spotlessApply detekt apiCheckapiCheck 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.
MIT License
Copyright (c) 2026 Ranbir Singh
See LICENSE for the full text.