PARQORE Passkeys Plugin for NativePHP Mobile#
End-to-end WebAuthn passkeys for NativePHP Mobile apps — native registration/sign-in UI (AuthenticationServices on iOS, Credential Manager on Android) plus a full Laravel relying-party server (challenge generation, cryptographic verification via web-auth/webauthn-lib, and a credential-storage contract your app implements).
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 end-to-end on a real device as of this writing. 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);}
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 them (php artisan vendor:publish --tag=passkeys-routes) and edit directly 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.
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 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.
Security#
See SECURITY.md for the full write-up. Headline points:
- Every challenge is single-use (
Cache::pull()) and independently TTL-bound (config('passkeys.challenge_ttl')) — see Challenges are single-use. - Verification failures never leak
web-auth/webauthn-lib's own exception detail to the client —PasskeyVerificationException's public message is always the sanitized default unless you construct it yourself. errorMessageonPasskeyCreationFailed/PasskeyAuthenticationFailedis documented, per each event's own docblock, as always the sanitized message the native layer reported — never a raw platform exception or stack trace.- Nothing in
src/logs a raw credential, assertion, or challenge value — check SECURITY.md if you add your own logging around these calls. - A WebAuthn user handle is not a secret, but it must still be random and non-guessable — see Credential repository.
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. At the time of writing, native iOS (Swift, AuthenticationServices) and Android (Kotlin, Credential Manager) implementations may still be in progress alongside this documentation, developed by a separate workstream in this repository. 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 (48 tests as of this writing, covering DTOs, ChallengeStore, the Passkeys manager, and FakePasskeys) and is accurate as written. What this documentation does not claim, because it cannot be verified from here, is that the native iOS/Android code has been built and exercised end-to-end on a real device or simulator — treat any statement about exact native UI behavior (timing, exact system prompt wording, exact cancellation semantics) as design intent matching the events/DTOs above, not independently verified behavior, until you've confirmed it against a real build on each platform.
| iOS | Android | |
|---|---|---|
| Passkey UI | AuthenticationServices (ASAuthorizationPlatformPublicKeyCredentialProvider) |
Credential Manager (CredentialManager API) |
| Minimum OS version | 18.0 | API 26 |
| 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. See LICENSE.