PARQORE Passkeys Plugin for NativePHP Mobile#
WebAuthn passkeys for NativePHP Mobile — native registration/sign-in UI plus a Laravel relying-party server.
Overview#
partek/passkeys gives a NativePHP Mobile app everything needed to let a user register and sign in with a passkey — a FIDO2/WebAuthn discoverable credential backed by Face ID, Touch ID, or Android's biometric/screen-lock unlock — without your app ever handling a password or touching raw cryptographic material itself.
use PARTek\Passkeys\Facades\Passkeys; // Server: build registration options for the current user$registrationOptions = Passkeys::registrationOptions($user); // Native: present the platform's own passkey creation UI$requestId = Passkeys::create($registrationOptions); // ...later, once PasskeyCreated fires...$credential = Passkeys::createdCredential($requestId); // Server: verify the response and persist the credentialPasskeys::completeRegistration($user, $credential, $registrationOptions['challenge_key']);
The plugin is split cleanly into two halves:
- Native UI — presents the OS's own passkey creation/sign-in prompt (
Passkeys.Create/Passkeys.Authenticate) and hands back the raw credential/assertion the platform produced. This is the part the nativephp.json manifest andresources/ios//resources/android/implement. - Laravel relying-party server — generates WebAuthn challenges, stores them for a single use (
Support\ChallengeStore), verifies a returned credential/assertion againstweb-auth/webauthn-lib, and hands verified credentials to your app's own storage via theCredentialRepositorycontract you implement and bind.
This plugin never stores credentials itself and never assumes an Eloquent model shape for your users — see Credential repository below, which is the one piece of integration work every app must do before anything else here works.
What this is not#
- Not a drop-in user database. This plugin verifies and hands you a
Webauthn\CredentialRecord(registration) or your app's own resolved user (authentication) — it never persists a credential or looks one up itself. You bind aCredentialRepositoryimplementation; see Credential repository. - Not a high-assurance/enterprise-attestation solution. Registration always requests
attestation: none(see Attestation below) — this plugin verifies that a credential genuinely came from a real platform ceremony (correct origin, correct relying party id, valid signature) but does not evaluate attestation trust chains or check a metadata service (MDS) to prove which model of authenticator was used. If your compliance requirements need authenticator-model attestation, this plugin's scope stops short of that. - Not a domain-configuration automation tool. Apple Associated Domains and Android Digital Asset Links both require files hosted on your own domain and (for iOS) a manual Xcode entitlement — none of this can be done by
nativephp.jsonalone. See Apple Associated Domains and Android Digital Asset Links — this is genuinely one of the most error-prone parts of shipping passkeys, budget real time for it. - Not password-manager sync/autofill management. Whether a passkey syncs via iCloud Keychain or Google Password Manager, and whether it appears in browser/OS-level autofill, is entirely platform and user-account behavior this plugin has no control over.
- Not verified through a full successful ceremony on a real device as of this writing — the bridge/event round trip is confirmed on iOS, a full registration/sign-in is not. See Platform behavior.
Installation#
composer require partek/passkeys
Register the plugin:
php artisan native:plugin:register partek/passkeys
If you haven't published NativePHP's plugin provider yet:
php artisan vendor:publish --tag=nativephp-plugins-provider
Publish the config file (recommended — you'll need to set relying_party_id correctly before shipping, see below):
php artisan vendor:publish --tag=passkeys-config
Optionally publish the routes file if you need to customize authorization or response shapes (see Front-end + backend integration):
php artisan vendor:publish --tag=passkeys-routes
Both are published together under --tag=partek-passkeys if you'd rather grab both at once.
Rebuild after installing or after a manifest change:
php artisan native:run
No Android permissions and no iOS Info.plist entries are declared by nativephp.json — presenting the platform's passkey UI needs no runtime permission grant on either platform (android.permissions and ios.info_plist are both empty in the manifest). What is required, and is not handled by nativephp.json, is Associated Domains / Digital Asset Links — see the next section.
Apple Associated Domains and Android Digital Asset Links#
Passkeys are scoped to a domain your app must prove it controls, via a well-known file your webserver serves, plus (on iOS) an app entitlement. Skipping or misconfiguring either of these is the most common reason passkey registration/sign-in silently fails on a real device even though everything else here is correct — read this section fully before you ship.
iOS: Associated Domains#
1. Serve apple-app-site-association from your domain.
At https://yourdomain.com/.well-known/apple-app-site-association (no file extension, served as application/json — or with no Content-Type restriction enforced by Apple, but application/json is the safe choice — over plain HTTPS, no redirects, and reachable without authentication):
{ "webcredentials": { "apps": ["TEAMID.com.example.yourapp"] }}
TEAMID is your Apple Developer Team ID (10 characters, found in developer.apple.com → Membership, or in Xcode's signing settings) and com.example.yourapp is your app's bundle identifier (nativephp.json's top-level app id / config('nativephp.app_id')). If your app also uses Universal Links, add an applinks key alongside webcredentials — passkeys only need webcredentials.
2. Add the com.apple.developer.associated-domains entitlement — manually, in Xcode. nativephp.json cannot do this.
nativephp.json's iOS manifest schema only supports min_version and info_plist keys (see nativephp.json — this plugin's own ios.info_plist is empty because Associated Domains isn't an Info.plist key at all, it's a code-signing entitlement). There is no manifest field this plugin — or any NativePHP plugin — can populate to add an entitlement. You must add it yourself:
- Open the generated iOS project in Xcode (
php artisan native:runor your app's usual iOS build step generates it). - Select your app target → Signing & Capabilities → + Capability → Associated Domains.
- Add an entry:
webcredentials:yourdomain.com.
This is a manual step you (or whoever owns the native build) must repeat any time the iOS project is regenerated from scratch, until NativePHP Mobile's manifest schema grows entitlement support.
Android: Digital Asset Links#
Serve assetlinks.json from your domain — no manifest or app-config change needed on the Android side.
At https://yourdomain.com/.well-known/assetlinks.json:
[ { "relation": ["delegate_permission/common.get_login_creds"], "target": { "namespace": "android_app", "package_name": "com.example.yourapp", "sha256_cert_fingerprints": [ "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5" ] } }]
relationmust be exactlydelegate_permission/common.get_login_creds— this is the Credential Manager / passkeys relation, notdelegate_permission/common.handle_all_urls(that one is for Android App Links and is unrelated to passkeys).package_nameis your app's Android application id (nativephp.json/config('nativephp.app_id')).sha256_cert_fingerprintslists the SHA-256 fingerprint(s) of the signing certificate used to sign the APK/AAB — get yours withkeytool -list -v -keystore your.keystore(look forSHA256:) or, for a Play App Signing release build, from Play Console → your app → Setup → App integrity → App signing key certificate. Include both your debug and release fingerprints as separate array entries if you want passkeys to work in both build variants.- Unlike iOS, Android's Credential Manager needs no entitlement, manifest entry, or
nativephp.jsonchange — verification happens purely by the OS checking the calling app's signature against this file over HTTPS at credential-creation/assertion time. Serving the correct file is the entire Android-side requirement.
relying_party_id and allowed_origins must match what you just configured#
config('passkeys.relying_party_id') must be (or be a valid registrable-domain suffix of) the exact domain serving both well-known files above — the webcredentials entry in apple-app-site-association and the package_name/fingerprint entry in assetlinks.json are both meaningless if relying_party_id doesn't point at that same domain. config('passkeys.allowed_origins') is checked for an exact origin match (scheme + host + port) by web-auth/webauthn-lib, unlike relying_party_id which allows a registrable-domain suffix — list every origin your app is actually served from. Getting either of these wrong is the single most common cause of a passkey ceremony that "looks right" in the native UI but fails verification server-side with PasskeyVerificationException.
Configuration#
Full reference for every key in config/passkeys.php:
| Key | Env var | Default | Meaning |
|---|---|---|---|
relying_party_id |
PASSKEYS_RELYING_PARTY_ID |
host parsed from config('app.url') |
The domain passkeys are scoped to. Must match your Associated Domains / Digital Asset Links setup above. Override explicitly once you're past local development — don't rely on the app.url-derived default in production. |
relying_party_name |
PASSKEYS_RELYING_PARTY_NAME |
config('app.name') |
Human-readable name shown in the native passkey UI. |
allowed_origins |
PASSKEYS_ALLOWED_ORIGINS (comma-separated) |
config('app.url') |
Exact origin(s) a response's clientDataJSON.origin must match. Unlike relying_party_id, checked for an exact match — list every origin your app is actually served from (web + whatever origin your mobile app's native passkey provider reports). |
secured_relying_party_ids |
PASSKEYS_SECURED_RP_IDS (comma-separated) |
[] |
Origins exempted from web-auth/webauthn-lib's normal HTTPS requirement (e.g. http://localhost:8000 for local dev). Never include a production origin here. |
challenge_ttl |
PASSKEYS_CHALLENGE_TTL |
300 (seconds) |
How long a registration/authentication challenge stays valid. Consumed exactly once regardless (see ChallengeStore) — this only bounds how long a client has to complete the ceremony before it expires unconsumed. |
user_verification |
PASSKEYS_USER_VERIFICATION |
preferred |
'required', 'preferred', or 'discouraged'. Applied to both registration and authentication unless overridden per call via the $overrides argument. |
resident_key |
PASSKEYS_RESIDENT_KEY |
preferred |
'required', 'preferred', or 'discouraged'. 'preferred' registers a discoverable credential when the platform supports it, without hard-requiring one — the safer default for broad device compatibility while still enabling username-first/discoverable sign-in. |
timeout_ms |
PASSKEYS_TIMEOUT_MS |
60000 |
Milliseconds the native platform UI should wait before giving up. Advisory — the platform may cap this lower. |
register_routes |
PASSKEYS_REGISTER_ROUTES |
true |
Set false to not register this package's routes at all (e.g. you've published and customized routes/passkeys.php). A published copy of the routes file always wins over the package's own registration, regardless of this setting. |
route_prefix |
PASSKEYS_ROUTE_PREFIX |
passkeys |
URL prefix for the four shipped routes (see below). |
route_middleware |
(not env-configurable) | ['web'] |
Middleware applied to the route group. Registration routes assume an authenticated user is available via $request->user() — add your own auth middleware here, or edit the published routes/passkeys.php directly. |
Credential repository — you must implement this#
web-auth/webauthn-lib itself deliberately has no repository interface to implement (an upstream design choice since 4.6.0) — PARTek\Passkeys\Contracts\CredentialRepository is this plugin's own, app-facing equivalent, and the plugin does not function until you bind an implementation. Calling Passkeys::registrationOptions()/authenticationOptions() without one bound throws immediately:
RuntimeException: No CredentialRepository is bound. Bind PARTek\Passkeys\Contracts\CredentialRepositoryto your own implementation in a service provider — see README "Credential repository".
The contract:
namespace PARTek\Passkeys\Contracts; use Webauthn\CredentialRecord; interface CredentialRepository{ public function findCredential(string $publicKeyCredentialId): ?CredentialRecord; /** @return array<int, CredentialRecord> */ public function findCredentialsForUserHandle(string $userHandle): array; public function saveCredential(CredentialRecord $credentialRecord): void; public function userHandleFor(mixed $user): string; public function userNameFor(mixed $user): string; public function userDisplayNameFor(mixed $user): string; public function resolveUserFromHandle(string $userHandle): mixed;}
It works in terms of your app's own user representation (mixed $user — typically your User model) rather than a WebAuthn-specific type, so this plugin never needs to know your user model's shape.
A few things that matter and are easy to get wrong:
$userHandleis WebAuthn'suser.id— an opaque, stable byte string scoped to this relying party. It must not be your user's email or a directly-guessable value like an autoincrementing id (the spec recommends a random value with no external meaning). Generate one (e.g.random_bytes(16)) and persist it on your user record the first timeuserHandleFor()is called for a user who doesn't have one yet — don't derive one from other identifying data on every call, or every ceremony will use a different handle and nothing will match.findCredential()/findCredentialsForUserHandle()/saveCredential()work with raw binary strings (publicKeyCredentialId,userHandle,credentialPublicKeyare all raw bytes onWebauthn\CredentialRecord, not base64) — if you store them in a database column, base64-encode before storing/querying and decode on the way out. See the worked example below.resolveUserFromHandle()is the reverse lookup used during authentication, after a credential has been cryptographically verified — returnnullif no matching user exists; the caller (Passkeys::completeAuthentication()) throwsPasskeyVerificationExceptionin that case rather than authenticating anyone.
A complete Eloquent implementation#
See examples/EloquentCredentialRepository.php, examples/PasskeyCredential.php (the Eloquent model), and the two migrations in examples/migrations/ for a full, realistic implementation: a passkey_credentials table, a passkey_user_handle column added to users, an Eloquent model that converts to/from Webauthn\CredentialRecord, and the repository class itself. Copy these into your app and adjust column/table names to taste.
Bind it in a service provider (typically AppServiceProvider::register()):
use App\Services\Passkeys\EloquentCredentialRepository;use PARTek\Passkeys\Contracts\CredentialRepository; public function register(): void{ $this->app->bind(CredentialRepository::class, EloquentCredentialRepository::class);}
Usage#
Front-end + backend integration#
The plugin ships four ready-made Laravel routes (registered automatically unless config('passkeys.register_routes') is false, under config('passkeys.route_prefix'), default passkeys):
| Method | URI | Controller action | Purpose |
|---|---|---|---|
POST |
/passkeys/registration/options |
PasskeyRegistrationController::options |
Build registration options for $request->user() (requires auth — 401 if not logged in). |
POST |
/passkeys/registration/complete |
PasskeyRegistrationController::complete |
Verify a returned credential and persist it via your CredentialRepository. |
POST |
/passkeys/authentication/options |
PasskeyAuthenticationController::options |
Build sign-in options. Accepts an optional login_hint to narrow to a known user's credentials (omit for discoverable/username-less sign-in). |
POST |
/passkeys/authentication/complete |
PasskeyAuthenticationController::complete |
Verify a returned assertion, resolve the user, and call Auth::login($user). |
Both controllers are thin by design — they hand off to the Passkeys facade for anything that matters. Publish the routes file (php artisan vendor:publish --tag=passkeys-routes copies routes/passkeys.php into your app; the controllers themselves are not published) and point the routes at your own controllers if your app needs different authorization, a different "who's registering" resolution, or a different response shape/session mechanism (the default authentication controller calls Laravel's session-based Auth::login() — swap that for your own token issuance if you're not using the session guard).
resources/js/index.js wraps only the raw native-bridge calls (create, createdCredential, authenticate, authenticatedAssertion, cancel) — it knows nothing about WebAuthn options or verification, which is why the shipped routes exist. The full flow, end to end:
1. POST /passkeys/registration/options → { challenge_key, options }2. create(options) → requestId (presents native UI)3. wait for PasskeyCreated (or poll) → the ceremony finished4. createdCredential(requestId) → raw credential5. POST /passkeys/registration/complete → { challenge_key, credential } → verified + persisted
Complete example: registration (Blade + JS)
This is real, runnable code (not pseudocode) for a page served over normal HTTP — e.g. an account-settings screen where an already-logged-in user adds a passkey. It polls createdCredential() rather than assuming any particular event-bridging setup, since PasskeyCreated/PasskeyCreationCancelled/PasskeyCreationFailed are dispatched as ordinary Laravel events in PHP and how you get notice of one in client-side JS depends on what event bridge your app already has (Echo/broadcasting, or none at all). See examples/registration-flow.blade.php for the full file this excerpt is drawn from, including a caveat on the ../../vendor/partek/passkeys/... import path — it matches the rest of this plugin portfolio's README convention, but exactly how a relative import inside an inline <script type="module"> resolves depends on your app's own Vite/asset setup; move the script into its own resources/js/*.js file and load it with @vite(...) if it doesn't resolve as-is.
<button id="add-passkey">Add a passkey</button><p id="passkey-status"></p> <script type="module">import { create, createdCredential } from '../../vendor/partek/passkeys/resources/js/index.js'; const csrfToken = document.querySelector('meta[name="csrf-token"]').content;const statusEl = document.getElementById('passkey-status'); async function postJson(url, body) { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: body ? JSON.stringify(body) : undefined, }); return { ok: response.ok, data: await response.json() };} // Poll createdCredential() until the native ceremony finishes or we time out.// A NativeComponent/Livewire screen can use #[On(PasskeyCreated::class)] instead// of polling — see the README's "Platform behavior" note on this.async function waitForCredential(requestId, { intervalMs = 500, timeoutMs = 60000 } = {}) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const credential = await createdCredential(requestId); if (credential) return credential; } catch { // Not finished yet — keep polling until the timeout. } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } return null;} document.getElementById('add-passkey').addEventListener('click', async () => { statusEl.textContent = 'Requesting options…'; const { ok: optionsOk, data: optionsData } = await postJson('/passkeys/registration/options'); if (!optionsOk) { statusEl.textContent = 'You must be signed in to add a passkey.'; return; } const { challenge_key: challengeKey, options } = optionsData; statusEl.textContent = 'Follow the prompt on your device…'; const requestId = await create(options); const credential = await waitForCredential(requestId); if (credential === null) { statusEl.textContent = 'Cancelled, or timed out waiting for a response.'; return; } statusEl.textContent = 'Verifying…'; const { ok, data } = await postJson('/passkeys/registration/complete', { challenge_key: challengeKey, credential, }); statusEl.textContent = ok ? 'Passkey added.' : (data.message ?? 'Could not verify the passkey.');});</script>
Complete example: sign-in (Blade + JS)
Same shape, using authenticate()/authenticatedAssertion() and the authentication routes. Omit login_hint for discoverable/username-less sign-in (the platform itself presents whichever passkeys it has for this relying party); pass it to narrow to a known account instead. See examples/sign-in-flow.blade.php for the full file.
<button id="sign-in-passkey">Sign in with a passkey</button><p id="signin-status"></p> <script type="module">import { authenticate, authenticatedAssertion } from '../../vendor/partek/passkeys/resources/js/index.js'; const csrfToken = document.querySelector('meta[name="csrf-token"]').content;const statusEl = document.getElementById('signin-status'); async function postJson(url, body) { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: body ? JSON.stringify(body) : undefined, }); return { ok: response.ok, data: await response.json() };} async function waitForAssertion(requestId, { intervalMs = 500, timeoutMs = 60000 } = {}) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const assertion = await authenticatedAssertion(requestId); if (assertion) return assertion; } catch { // Not finished yet. } await new Promise((resolve) => setTimeout(resolve, intervalMs)); } return null;} document.getElementById('sign-in-passkey').addEventListener('click', async () => { statusEl.textContent = 'Requesting options…'; // No login_hint — discoverable sign-in. The platform shows whichever // passkeys it has for this relying party; the verified credential's own // userHandle resolves the account server-side. const { data: optionsData } = await postJson('/passkeys/authentication/options'); const { challenge_key: challengeKey, options } = optionsData; statusEl.textContent = 'Follow the prompt on your device…'; const requestId = await authenticate(options); const assertion = await waitForAssertion(requestId); if (assertion === null) { statusEl.textContent = 'Cancelled, or timed out waiting for a response.'; return; } statusEl.textContent = 'Verifying…'; const { ok, data } = await postJson('/passkeys/authentication/complete', { challenge_key: challengeKey, assertion, }); statusEl.textContent = ok ? 'Signed in.' : (data.message ?? 'Could not verify the passkey.'); if (ok) window.location.reload();});</script>
Alternative: calling the facade directly from a NativeComponent
If your screen is a NativeComponent (Livewire-style, as in the sibling document-intelligence/print-studio plugins) rather than a plain HTTP page, you don't need the JSON routes at all — call the Passkeys facade directly in PHP and listen for outcome events with #[On(...)], exactly like every other async plugin in this portfolio:
use Native\Mobile\Attributes\On;use Native\Mobile\Edge\NativeComponent;use PARTek\Passkeys\DTO\PasskeyCredential;use PARTek\Passkeys\Events\PasskeyCreated;use PARTek\Passkeys\Facades\Passkeys; class AddPasskeyScreen extends NativeComponent{ public ?string $requestId = null; public ?string $challengeKey = null; public ?string $status = null; public function addPasskey(): void { $options = Passkeys::registrationOptions($this->user()); $this->challengeKey = $options['challenge_key']; $this->requestId = Passkeys::create($options); } #[On(PasskeyCreated::class)] public function onCreated(string $requestId): void { if ($requestId !== $this->requestId) { return; } $credential = Passkeys::createdCredential($requestId); Passkeys::completeRegistration($this->user(), $credential, $this->challengeKey); $this->status = 'Passkey added.'; }}
Both patterns hit the exact same Passkeys manager underneath — pick whichever matches how the rest of your screen is built.
Events#
| Event | Fires | Properties |
|---|---|---|
PasskeyRegistrationStarted |
Synchronously in PHP, the instant create() is called — before the native bridge call. |
requestId (string) |
PasskeyCreated |
The native platform returned a new credential. Not yet a registered passkey — call createdCredential($requestId) then completeRegistration() to verify and persist it. A credential the server never verifies must never be trusted. |
requestId (string) |
PasskeyCreationCancelled |
The user dismissed the native passkey creation UI — a normal, first-class outcome, not an error. | requestId (string) |
PasskeyCreationFailed |
The native side reported a failure. | requestId (string), errorCode (string), errorMessage (string — always the sanitized message the native layer reported, never a raw platform exception or stack trace) |
PasskeyAuthenticationStarted |
Synchronously in PHP, the instant authenticate() is called — before the native bridge call. |
requestId (string) |
PasskeyAuthenticated |
The native platform returned an assertion. Not yet a verified sign-in — call authenticatedAssertion($requestId) then completeAuthentication(). An assertion the server never verifies must never be trusted to authenticate anyone. |
requestId (string) |
PasskeyAuthenticationCancelled |
The user dismissed the native sign-in UI, or no matching credential was found on the device — a normal, first-class outcome, not an error. | requestId (string) |
PasskeyAuthenticationFailed |
The native side reported a failure. | requestId (string), errorCode (string), errorMessage (string — always sanitized) |
Only the 6 native-dispatched events (PasskeyCreated, PasskeyCreationCancelled, PasskeyCreationFailed, PasskeyAuthenticated, PasskeyAuthenticationCancelled, PasskeyAuthenticationFailed) are declared in nativephp.json's events array and bound by constructor parameter name from the native event payload (the same NativeComponent::makeEventInstance() mechanism every other plugin in this portfolio uses) — which is why all 6 have flat, scalar-only constructors, by design. PasskeyRegistrationStarted and PasskeyAuthenticationStarted are dispatched directly in PHP from inside create()/authenticate() themselves, are deliberately not declared in the manifest, and exist purely so a listener sees every request that was attempted, even one native never responds to.
Methods#
registrationOptions(mixed $user, array $overrides = []): array{challenge_key: string, options: array}#
Builds WebAuthn creation options for $user (your own user representation — never assumed to be Eloquent). Calls your bound CredentialRepository for userHandleFor()/userNameFor()/userDisplayNameFor()/findCredentialsForUserHandle() (the latter populates excludeCredentials, so a user can't register the same authenticator twice). $overrides accepts userVerification/residentKey to override the configured defaults for this call only.
create(array $registrationOptions): ?string#
Presents the native passkey creation UI. Asynchronous — like every native UI presentation in this portfolio, a biometric/passkey prompt is an indeterminate-duration, user-interactive ceremony, so this returns the new request's id (or null if the native call wasn't even accepted), never a credential directly. Watch for PasskeyCreated/PasskeyCreationCancelled/PasskeyCreationFailed.
createdCredential(string $requestId): ?PasskeyCredential#
Fetches the native credential after PasskeyCreated fires. Not yet verified — never persist or trust this without calling completeRegistration() first.
completeRegistration(mixed $user, PasskeyCredential $credential, string $challengeKey, ?string $host = null): CredentialRecord#
Verifies $credential against the options issued for $challengeKey and persists it via your CredentialRepository. Throws InvalidChallengeException if the challenge is missing/expired/already used, or PasskeyVerificationException if web-auth/webauthn-lib rejects the response (wrong origin, wrong relying party id, bad signature, credential id collision, etc.) — every path either returns a persisted CredentialRecord or throws; there is no "maybe valid" result.
authenticationOptions(mixed $loginHint = null, array $overrides = []): array{challenge_key: string, options: array}#
Builds WebAuthn request options for sign-in. $loginHint is only used to narrow allowCredentials for a known username-first flow — omit it (the default) for a discoverable/username-less flow, where the platform itself presents whichever passkeys it has for this relying party and the credential's own userHandle resolves the account after verification.
authenticate(array $authenticationOptions): ?string#
Presents the native sign-in UI. Same async-acceptance contract as create(). Watch for PasskeyAuthenticated/PasskeyAuthenticationCancelled/PasskeyAuthenticationFailed.
authenticatedAssertion(string $requestId): ?PasskeyAssertion#
Fetches the native assertion after PasskeyAuthenticated fires. Not yet verified — never treat this as a successful sign-in without calling completeAuthentication() first.
completeAuthentication(PasskeyAssertion $assertion, string $challengeKey, ?string $host = null): mixed#
Verifies $assertion and resolves the signed-in user via your CredentialRepository. Throws InvalidChallengeException/PasskeyVerificationException on any failure — never returns a "maybe authenticated" result. Never trust a client-supplied user identity separately from what this method resolves; the verified userHandle is the only source of truth for who signed in. Returns whatever CredentialRepository::resolveUserFromHandle() returns — your own user representation, not a WebAuthn type.
cancel(string $requestId): bool#
Requests cancellation of an in-flight create()/authenticate() request by id. Returns whether the native side accepted the request, not confirmation it actually stopped.
Result shapes#
PasskeyCredential (registration)#
final readonly class PasskeyCredential{ public string $id; public string $rawId; public string $clientDataJson; public string $attestationObject; /** @var string[] */ public array $transports;}
Every field is a base64url string exactly as the platform's credential provider reports it — this plugin's iOS and Android native code both normalize to this same shape (the standard WebAuthn JSON credential shape a browser's own PublicKeyCredential.toJSON() would produce), so application code never sees a platform-specific field name or has to base64url-encode/decode anything itself. PasskeyCredential::fromArray() accepts either snake_case (raw_id, client_data_json, attestation_object) or camelCase (rawId, clientDataJSON, attestationObject) keys.
Both PasskeyCredential::fromArray() and PasskeyAssertion::fromArray() throw PasskeyVerificationException if a field is present but isn't a string (for example an array in a tampered request body), so the shipped controllers answer with their usual 422.
PasskeyAssertion (authentication)#
final readonly class PasskeyAssertion{ public string $id; public string $rawId; public string $clientDataJson; public string $authenticatorData; public string $signature; public ?string $userHandle;}
$userHandle is present when the platform authenticator returned one — always true for a discoverable/passkey credential, which is what this plugin exclusively deals in.
Challenges are single-use#
Support\ChallengeStore stores the complete serialized PublicKeyCredentialCreationOptions/PublicKeyCredentialRequestOptions JSON under a random key (not just the raw challenge bytes — web-auth/webauthn-lib's validators check a response against the whole original options object, not the challenge alone). consume() atomically reads and deletes in one operation (Cache::pull()), so a replayed verification request — the same challenge_key submitted twice — finds nothing the second time, regardless of whether the first attempt succeeded or failed. Entries also expire on their own via config('passkeys.challenge_ttl'), independent of consumption.
Attestation: none, and why#
Registration always requests attestation: 'none'. Per the comment directly in Passkeys::registrationOptions():
'none': this plugin verifies the credential came from a genuine platform ceremony (origin, rp id, signature) — it does not evaluate attestation trust chains/MDS, which is a separate, out-of-scope concern. Requesting anything more than'none'would collect attestation data this plugin never uses.
In practice: you get a strong guarantee that a given credential was produced by a real WebAuthn ceremony against your relying party, but no guarantee about which authenticator model produced it. This is the right tradeoff for broad-compatibility consumer passkey auth and is what essentially every "sign in with a passkey" implementation you've used does — it is not the right tool if your threat model specifically requires authenticator-attestation evaluation.
Exceptions#
| Exception | Thrown by | Meaning |
|---|---|---|
InvalidChallengeException |
completeRegistration(), completeAuthentication() |
The given challenge_key doesn't resolve to a stored challenge — it already expired, was already consumed (replay), or never existed. Distinct from a verification failure. |
PasskeyVerificationException |
completeRegistration(), completeAuthentication() |
web-auth/webauthn-lib rejected the response — wrong origin, wrong relying party id, signature verification failed, unknown credential id, and so on. Wraps the library's own exception with a sanitized message (default: "Passkey verification failed.") — never expose the original exception's message to an end user, it can be specific enough to help an attacker probe the relying party. Log $previous server-side if you need the detail. |
Both extend RuntimeException. The shipped controllers catch both and return a 422 with a sanitized {"message": "..."} body — see Http/Controllers.
Testing#
There are two ways to test code that uses this plugin. Passkeys::fake() replaces the facade and fakes server-side verification, which suits service classes, controllers and plain PHP tests. The test helpers work with NativePHP's component test harness (Native::test()), which suits screens: the screen calls the real facade, the helpers answer the bridge the way the device does, and they deliver the native events.
Testing with the fake#
Passkeys::fake() swaps the bound manager for FakePasskeys, which records every create()/authenticate()/cancel() call instead of crossing the native bridge, and overrides completeRegistration()/completeAuthentication() entirely, not just their native calls — real WebAuthn verification needs a genuine authenticator-signed response, which no test can produce, so faking at the crypto-verification boundary (not just the bridge) is the only way to test an app's own registration/login flow without a real device.
FakePasskeys ships a self-contained InMemoryCredentialRepository so registrationOptions()/authenticationOptions() work out of the box without your app having bound its own repository first — call usingRepository() to swap in a real one if a test needs to.
use PARTek\Passkeys\DTO\PasskeyAssertion;use PARTek\Passkeys\DTO\PasskeyCredential;use PARTek\Passkeys\Exceptions\PasskeyVerificationException;use PARTek\Passkeys\Facades\Passkeys; it('registers a passkey', function () { Passkeys::fake(); $options = Passkeys::registrationOptions('user-1'); $requestId = Passkeys::create($options); $credential = new PasskeyCredential('cred-id', 'raw-id', 'client-data', 'attestation'); Passkeys::completeRegistration('user-1', $credential, $options['challenge_key']); Passkeys::fake()->assertRegistered(fn ($user) => $user === 'user-1');}); it('signs a known user in without a real device', function () { $fake = Passkeys::fake()->authenticateAs($myUser); $assertion = new PasskeyAssertion('cred-id', 'raw-id', 'client-data', 'auth-data', 'sig'); $user = Passkeys::completeAuthentication($assertion, 'any-challenge-key'); expect($user)->toBe($myUser); $fake->assertAuthenticated();}); it('simulates a failed verification', function () { Passkeys::fake()->failing(); Passkeys::completeRegistration('user-1', new PasskeyCredential('id', 'raw', 'data', 'attest'), 'any-key');})->throws(PasskeyVerificationException::class); it('asserts nothing was called', function () { Passkeys::fake()->assertNothingCalled();});
| Method | Purpose |
|---|---|
usingRepository(CredentialRepository $repository): static |
Swap the default InMemoryCredentialRepository for your own — chainable. |
failing(bool $failing = true): static |
Make completeRegistration()/completeAuthentication() throw PasskeyVerificationException, as if a real device sent an invalid response — chainable. |
authenticateAs(mixed $user): static |
Make completeAuthentication() return $user directly, with no crypto verification performed at all — chainable. Required before calling completeAuthentication() on the fake, or it throws RuntimeException. |
assertRegistered(?callable $callback = null): void |
Asserts a completeRegistration() call happened; the optional callback receives ($user, $credential) to narrow the match. |
assertAuthenticated(?callable $callback = null): void |
Asserts a completeAuthentication() call happened; the optional callback receives ($assertion). |
assertCancelled(?string $requestId = null): void |
Asserts a cancel() call happened; pass a request id to require that specific one. |
assertNothingCalled(): void |
Asserts no create()/authenticate()/cancel() call was issued at all. |
calls(): array |
The raw list of every recorded {method, params} call, for assertions the helpers above don't cover. |
completeRegistration() on the fake, unless failing() is set, builds and persists a real Webauthn\CredentialRecord (attestation type none, empty trust path, a random AAGUID and public key) into whichever CredentialRepository is currently bound — so code that reads back a just-registered credential in the same test still works.
Test helpers#
On NativePHP Mobile 4.0 or later, the plugin registers these helpers on Native::fakeBridge() and on the Native::test() harness whenever the app runs under a test runner (APP_ENV=testing). Production boots never register them, and there is nothing extra to install.
use App\NativeComponents\AddPasskeyScreen;use Native\Mobile\Testing\Native; it('shows the new passkey', function () { Native::fakeBridge()->withPasskeys(); Native::test(AddPasskeyScreen::class) ->tap('Add passkey') ->assertPasskeyRegistrationStarted() ->completePasskeyRegistration() ->assertSee('Passkey created');});
Script the bridge first. With nothing scripted, the fake bridge answers null and the facade treats that as a rejected call: create() and authenticate() return null and cancel() returns false. The bridge assertions would still pass while your screen runs its failure branch.
| Helper | Called on | What it does |
|---|---|---|
withPasskeys() |
bridge | create(), authenticate() and cancel() are accepted. Each answers {accepted: true}, as the native side does for a request in flight. |
assertPasskeyRegistrationStarted() |
bridge | create() reached the bridge. |
assertPasskeyAuthenticationStarted() |
bridge | authenticate() reached the bridge. |
completePasskeyRegistration(array $credential = [], ?string $requestId = null) |
harness | Scripts createdCredential() for the request, then delivers PasskeyCreated. |
completePasskeyAuthentication(array $assertion = [], ?string $requestId = null) |
harness | Scripts authenticatedAssertion() for the request, then delivers PasskeyAuthenticated. |
cancelPasskeyRegistration(?string $requestId = null) |
harness | Delivers PasskeyCreationCancelled, as when the user dismisses the sheet. |
cancelPasskeyAuthentication(?string $requestId = null) |
harness | Delivers PasskeyAuthenticationCancelled, as when the user dismisses the sheet or has no passkey. |
failPasskeyRegistration(string $errorCode = 'failed', string $errorMessage = '', ?string $requestId = null) |
harness | Delivers PasskeyCreationFailed. |
failPasskeyAuthentication(string $errorCode = 'failed', string $errorMessage = '', ?string $requestId = null) |
harness | Delivers PasskeyAuthenticationFailed. |
Bridge helpers work on Native::fakeBridge() or straight off the harness. Called off the harness, they return the harness, so the chain continues.
- Request ids. The registration helpers use the
request_idof the lastcreate()call, and the sign-in helpers the lastauthenticate()call, unless you pass$requestId. With no such call they fail with a message naming the helper. - What the complete helpers script. The payload the device returns, in snake_case:
{id, raw_id, client_data_json, attestation_object, transports}for a credential and{id, raw_id, client_data_json, authenticator_data, signature, user_handle}for an assertion.client_data_jsoncarries the request's challenge; the other binary fields are placeholders. Keys you pass replace the defaults, e.g.completePasskeyAuthentication(['user_handle' => null]). Only the completed request gets the payload, and only once, as on Android:createdCredential()for any other id, or a second time for the same id, returnsnull. - Verification. The scripted credential and assertion are not signed, so
completeRegistration()andcompleteAuthentication()reject them. The helpers cover the device side of a screen; test verification withPasskeys::fake(). - Error codes. iOS sends
failed,invalid_response,not_handled,not_interactive,matched_excluded_credential,already_in_progress,invalid_parametersand a few others. Android sends the Credential Manager exception'stypestring, orunknown_error. The defaultfailedis iOS's generic code, so pass the code your screen handles. - With
Passkeys::fake(). The fake replaces the facade, so nothing reaches the bridge while it is active: the bridge helpers see no calls, and the harness helpers can't find a request id. The harness helpers still deliver events if you pass the id, e.g.->cancelPasskeyRegistration(requestId: $screen->get('requestId')), andcreatedCredential()then answers from the fake.
Security#
- Challenges are single-use and TTL-bound; origins/
relying_party_idare strictly validated. - Verification failures return sanitized messages only — no raw library internals to clients.
- Nothing in
src/logs raw credentials, assertions, or challenges.
Report a vulnerability privately: email [email protected] with impact, repro steps, and plugin/NativePHP versions. Do not open a public GitHub issue. Please allow reasonable time for a fix before disclosure.
Troubleshooting#
registrationOptions()/authenticationOptions() throws RuntimeException: No CredentialRepository is bound. You haven't bound PARTek\Passkeys\Contracts\CredentialRepository yet — see Credential repository.
completeRegistration()/completeAuthentication() throws InvalidChallengeException. The challenge_key you passed back doesn't resolve to a stored challenge — it's already expired (config('passkeys.challenge_ttl'), default 300s), was already consumed by an earlier call (challenges are single-use, success or failure), or was never issued by this app. There's no way to recover an expired/consumed challenge — the client must request fresh options and restart the ceremony.
completeRegistration()/completeAuthentication() throws PasskeyVerificationException. web-auth/webauthn-lib rejected the response. The most common real-world causes, in rough order of likelihood: relying_party_id doesn't match the domain in your apple-app-site-association/assetlinks.json files (see Apple Associated Domains and Android Digital Asset Links); allowed_origins doesn't exactly match the origin the client actually used; you're testing over plain HTTP against a host not listed in secured_relying_party_ids; or the credential genuinely doesn't belong to this relying party. Log the exception's $previous (never its own public message, which is deliberately sanitized) to see webauthn-lib's real reason.
create()/authenticate() returns null immediately. The native call was never accepted — check that the plugin is registered (php artisan native:plugin:register partek/passkeys) and that you've rebuilt (php artisan native:run) since installing or updating it. This is a PHP-side "the call didn't get through" signal, distinct from PasskeyCreationFailed/PasskeyAuthenticationFailed, which means it did get through and then failed natively.
The native UI never appears, or Associated Domains/Digital Asset Links verification silently fails. Re-check both well-known files are reachable over plain HTTPS with no redirect and no auth wall, that the iOS entitlement was actually added in Xcode (it does not persist automatically — see Apple Associated Domains and Android Digital Asset Links), and that the Android signing certificate fingerprint in assetlinks.json matches the exact build variant (debug vs. release) you're testing with.
Registration succeeds but a returning user's passkey isn't offered at sign-in. Check findCredentialsForUserHandle()/findCredential() in your CredentialRepository implementation — a credential that was verified and "saved" but not actually persisted correctly (e.g. a raw-bytes/base64 mismatch on the storage column) will silently never come back. See the worked example in examples/.
Platform behavior#
The bridge function names (Passkeys.Create, Passkeys.CreatedCredential, Passkeys.Authenticate, Passkeys.AuthenticatedAssertion, Passkeys.Cancel), event names, and DTO shapes documented above are the fixed contract between this package and its native plugin code (resources/ios/, resources/android/) — see nativephp.json. Everything above the "Platform behavior" line describes the PHP relying-party server, DTOs, events, testing fake, and manifest contract — all of which is exercised by this package's own passing Pest suite (covering DTOs, ChallengeStore, the Passkeys manager, the shipped controllers, FakePasskeys and the test helpers) and is accurate as written.
iOS, confirmed on a real device (2026-09-24): the full bridge round trip — PHP create()/authenticate() → native AuthenticationServices accepts the request → the correctly-named PasskeyCreationFailed/PasskeyAuthenticationFailed event fires back into PHP — is genuinely exercised end to end, not just designed. That test ran without the associated-domains entitlement or a served well-known file, so the ceremony itself was expected to fail; what it confirmed is that the plumbing works and fails cleanly, not a full successful registration/sign-in. A fully successful ceremony (needs a real reachable relying-party domain plus the Xcode entitlement) and the mid-ceremony-cancel path (needs a real in-flight Face ID/Touch ID sheet and a person to cancel it) remain unconfirmed — treat exact native UI behavior (timing, exact system prompt wording, exact cancellation semantics) as design intent until you've confirmed those specifically. Android is unconfirmed on a real device as of this writing.
| iOS | Android | |
|---|---|---|
| Passkey UI | AuthenticationServices (ASAuthorizationPlatformPublicKeyCredentialProvider) |
Credential Manager (CredentialManager API) |
| Minimum OS version | 18.0 | API 28 |
| Runtime permission | None required (nativephp.json's ios.info_plist is empty) |
None required (nativephp.json's android.permissions is empty) |
| Domain association | Apple Associated Domains (apple-app-site-association + Xcode entitlement) |
Android Digital Asset Links (assetlinks.json only) |
License#
Proprietary commercial. All rights reserved © 2026 PARTek. Unauthorized copying or redistribution is prohibited except under a written license with PARTek. The full license text ships in the LICENSE file included with this package (not linked from a private GitHub URL).