Skip to content

Passkey Authentication

Overview

Passkey Authentication lets back-office (merchant) users verify their identity using the WebAuthn standard instead of an email OTP when logging in from an unrecognized device. Users register biometric credentials (fingerprint, Face ID) or hardware security keys as passkeys from their profile, then use them as one of the step-up authentication options during login.

Passkeys are a phishing-resistant credential type built on public-key cryptography: the private key never leaves the user's device, and only the public key (serialized PublicKeyCredentialSource) is stored on the server.

Important scope note (verified in code): passkeys are not a standalone "passwordless login" that replaces the password field. The password login flow still starts with email + password (loginWithDeviceCheck in AuthService). Only when the device is not recognized does the back office present an authentication-method screen (Email OTP / SMS OTP / Passkey), and Passkey is offered there only if the user has at least one registered passkey. Earlier descriptions of passkeys as a full password replacement are inaccurate.

There is a second sign-in path that never reaches passkeys at all: Google sign-in (POST /api/login/google, rendered on the login page whenever VITE_GOOGLE_CLIENT_ID is configured). A verified Google sign-in is treated as the strong factor and deliberately bypasses the device-trust challenge, so a user signing in with Google never sees the step-up screen and is never offered a passkey.

Purpose

The /profile/passkeys page lets a logged-in user manage passkey credentials for their own account: register a new passkey with a custom name, view existing passkeys, and delete passkeys they no longer use. The login page additionally lets the user authenticate with a registered passkey as an alternative to an email/SMS OTP when signing in from an untrusted device.

Key Concepts

  • Passkey: A WebAuthn credential stored on the user's device (or a hardware token). On the server, each passkey is an entry in the user's passkeys array containing a human-readable name, the serialized passkey (a PublicKeyCredentialSource, which includes the credential ID and public key), a created_at timestamp (a MongoDB UTCDateTime set at registration), and, after the first successful login with it, an updated_at timestamp.
  • WebAuthn Ceremony: The cryptographic handshake between browser and server. Registration uses an attestation ceremony, validated server-side with AuthenticatorAttestationResponseValidator. Login uses an assertion ceremony, validated with AuthenticatorAssertionResponseValidator. Both rely on a server-generated challenge persisted on the user document.
  • Relying Party (RP): The server entity passkeys are bound to. The RP name is config('app.name') and the RP ID is the hostname of config('app.backoffice_url') (parse_url(..., PHP_URL_HOST)). Passkeys only work on the domain where they were registered.
  • Challenge: For both registration (getPasskeySetup) and login challenge (getPasskeyChallenge), the service generates 32 random bytes via the private generateRandomBytes(32) helper (which calls random_bytes(32)). It then builds the full PublicKeyCredentialCreationOptions / PublicKeyCredentialRequestOptions, serializes the whole options object to JSON, and stores that JSON string on the user's webauthn_challenge field -- not just the raw challenge bytes. The challenge is single-use and overwritten when a new ceremony begins.
  • Device Trust: During passkey login, the client may send trust_device: true. When set, AuthService::authenticateWithPasskey records a trusted device entry ({fingerprint, ip, user_agent, trusted_at}, where the fingerprint = SHA-256 of IP|user-agent and trusted_at is a Unix time() integer) on the user so future logins from that device skip the step-up screen. Device trust expires after 30 days (User model). In the back office the PasskeyAuthentication component defaults trustDevice to true (UI label: "Trust this device for 30 days").

Route

  • Backoffice Route: /profile/passkeys (management page; reached from the user profile dropdown menu). Passkey login surfaces inside /login.
  • Backend Controller: app/Http/Controllers/Api/AuthController.php
  • Passkey Service: app/Services/PasskeyService.php (WebAuthn ceremony logic and credential storage)
  • Auth Service: app/Services/AuthService.php (thin wrappers that inject the authenticated user and handle login/device-trust side effects)
  • Request Validators: app/Http/Requests/Auth/SetupPasskeyRequest.php, app/Http/Requests/Auth/PasskeyAuthRequest.php, app/Http/Requests/Auth/GetUserPasskeyRequest.php, app/Http/Requests/Auth/DeletePasskeyRequest.php
  • Routes: authenticated management routes in routes/api.php under /back-office/passkeys (inside the auth + type:backoffice + tenant:backoffice + check-user-activity middleware group); guest login routes in routes/api/guest.php under /back-office (guest middleware)
  • Vue Components: src/pages/profile/passkeys/index.vue, src/views/passkeys/PassKeys.vue, src/views/passkeys/PasskeySetupDialog.vue, src/views/passkeys/PasskeyDeleteDialog.vue, src/views/authentication/PasskeyAuthentication.vue, src/views/authentication/AuthMethodSelection.vue, src/views/authentication/LoginForm.vue (orchestrates the steps)
  • Frontend helpers: src/utils/webauthn.ts (WebAuthn support detection and base64/base64url helpers); the dialogs delegate the actual browser ceremony to @simplewebauthn/browser (startRegistration / startAuthentication)
  • Trait: app/Traits/DeviceTrackingTrait.php (client IP, user agent, device fingerprint, base64url helpers)

All API paths below are prefixed with /api (the back-office client base is ${BACKEND_API}/api).

Actions

Get Passkey Setup Options (registration challenge)

Generates PublicKeyCredentialCreationOptions for a new passkey: builds the RP entity (app name + backoffice hostname) and user entity (email, user ID, display name) with a fresh 32-byte challenge, serializes the options to JSON, stores that JSON on the user's webauthn_challenge, and returns it.

  • Endpoint: GET /api/back-office/passkeys/setup (authenticated; type:backoffice + tenant:backoffice + check-user-activity)
  • Response: Serialized PublicKeyCredentialCreationOptions JSON (returned as a raw Response)

Register a Passkey

Completes the registration ceremony. Receives the attestation response (passkey, a JSON string) plus a name. The server:

  1. Deserializes the credential with the WebAuthn serializer.
  2. Rejects (HTTP 400, "Passkey with this id already exists") if an existing passkey already uses that name.
  3. Verifies the response is an AuthenticatorAttestationResponse (else 400, "Invalid passkey response").
  4. Builds a CeremonyStepManagerFactory; in a local environment (app()->isLocal()) it adds localhost as an allowed origin.
  5. Validates the attestation against the stored options and the backoffice host. Any failure returns 400, "Invalid passkey response".
  6. Appends {name, passkey (serialized PublicKeyCredentialSource), created_at (UTCDateTime)} to the user's passkeys array and saves it.
  • Endpoint: POST /api/back-office/passkeys/setup (authenticated; type:backoffice + tenant:backoffice + check-user-activity)
  • Request Body: name (required, string, max 255), passkey (required, JSON string)
  • Response: {status: "success", message: "Passkey registered successfully"}

Get Passkey Challenge (login challenge)

Initiates a login ceremony for an email address. Looks up the user, maps each stored passkey to a PublicKeyCredentialDescriptor for allowCredentials, generates a 32-byte challenge, builds PublicKeyCredentialRequestOptions, serializes them, and (if the user exists) stores the serialized options on webauthn_challenge. The serialized options are always returned. If the user has no passkeys, allowCredentials is empty -- the back office treats that as "no passkeys available" and aborts before prompting.

  • Endpoint: POST /api/back-office/passkeys (guest route, no authentication)
  • Request Body: email (required, string)
  • Response: Serialized PublicKeyCredentialRequestOptions JSON (raw Response)

Authenticate with Passkey

Completes the login assertion ceremony, then completes login. PasskeyService::authenticateWithPasskey:

  1. Deserializes the credential and confirms it is an AuthenticatorAssertionResponse (else 400).
  2. Looks up the user by matching the credential id against the nested field passkeys.passkey.publicKeyCredentialId (retrieveByFilter(..., throw: false)); 400 "User not found" if none.
  3. Finds the matching passkey entry (400 "Passkey not found" if missing) and deserializes its PublicKeyCredentialSource.
  4. Validates the assertion against the stored challenge options, the passkey source, the backoffice host, and userHandle = user ID (failure -> 400 "Invalid passkey response").
  5. Re-serializes the updated source (refreshed signature counter), keeps the original created_at, and sets updated_at (UTCDateTime); saves the array.

AuthService::authenticateWithPasskey then clears challenge_token, challenge_token_expires_at, and webauthn_challenge (and resets remember_me to false); optionally records a trusted device when trust_device is true; and returns the standard completeLogin payload.

  • Endpoint: POST /api/back-office/authenticate-passkey (guest route)
  • Request Body: passkey (required, JSON string), trust_device (optional, boolean)
  • Response: {status: "logged_in", token: ..., ...user data} (same shape as a normal login)

View Passkeys

Lists the authenticated user's passkeys. Each entry is reduced to id (set equal to name), name, and created_at (converted from UTCDateTime to a Unix timestamp). Cryptographic fields and updated_at are not returned.

  • Endpoint: GET /api/back-office/passkeys (authenticated; type:backoffice + tenant:backoffice + check-user-activity)
  • Response: Array of {id, name, created_at} objects

Delete a Passkey

Removes a passkey by name: filters it out of the passkeys array (array_filter), re-indexes (array_values), and saves.

  • Endpoint: DELETE /api/back-office/passkeys (authenticated; type:backoffice + tenant:backoffice + check-user-activity)
  • Request Body: name (required, string)
  • Response: Standard success response

Fields

FieldIDTypeRequiredValidation
Passkey NamenameStringYes (setup / delete)required|string|max:255 (setup); required|string (delete)
Passkey CredentialpasskeyJSON stringYes (setup / auth)required|json -- serialized WebAuthn credential
Trust Devicetrust_deviceBooleanNo (auth only)boolean -- adds the device to trusted devices on success
EmailemailStringYes (login challenge)required|string -- email used to look up the user

Business Rules

  • A user cannot register two passkeys with the same name. setupPasskey iterates existing passkeys and aborts with HTTP 400 "Passkey with this id already exists" on a name collision.
  • The WebAuthn RP ID is the hostname of config('app.backoffice_url') (UPVENDO_BACKOFFICE_URL, default http://localhost:5174). Passkeys are domain-bound and will not work if the backoffice host changes.
  • In a local environment (app()->isLocal()), the CeremonyStepManagerFactory adds localhost as an allowed origin so WebAuthn can be tested without production HTTPS. Outside local, only the configured backoffice host is accepted.
  • Each successful passkey login re-serializes the credential source (refreshing its signature counter) and sets the passkey's updated_at. (The verification result, including any counter check, comes from the web-auth/webauthn-lib validator -- not-verified-here whether a counter regression hard-fails.)
  • The webauthn_challenge field is shared between registration and login ceremonies, so only one ceremony can be active per user at a time; starting a new one overwrites the previous challenge. After login it is cleared.
  • During login, the user is identified by the credential ID via the nested filter passkeys.passkey.publicKeyCredentialId, with throw: false to return null (then a 400) rather than throw.
  • Only none attestation is registered (NoneAttestationStatementSupport), so the system accepts self-attestation without requiring an attestation certificate chain.
  • Email verification is a hard precondition for the whole step-up flow. loginWithDeviceCheck returns status: 'verification_required' and stops before evaluating device trust when the user's email address is not verified, so an unverified user is never offered Passkey (or OTP) no matter how many passkeys they have registered — they must verify first (POST /api/back-office/verify-email, resend via POST /api/back-office/resend-verification). (AuthService::loginWithDeviceCheck, app/Services/AuthService.php:700-707)
  • Passkey login is gated behind the device-step-up flow. When a normal email/password login comes from an untrusted device, loginWithDeviceCheck returns an available_methods payload that always lists a passkey method, but its available flag is set to hasPasskeys($user) (true only when the user has at least one registered passkey). The back office only renders the Passkey option when availableMethods.passkey?.available is true, so in practice passkey is offered only to users who have registered one.

Customer Impact

  • Kiosk: No direct impact. Passkeys apply to back-office (merchant) user login only.
  • Online Ordering: No direct impact on customer-facing online ordering.
  • Backoffice UX: When signing in from a new/untrusted device, users with a registered passkey can complete the step-up verification with a fingerprint, Face ID, or security key instead of waiting for an email/SMS OTP. Day-to-day logins from already-trusted devices are unaffected.
  • Security Posture: Passkeys are phishing-resistant and domain-bound, strengthening the step-up verification compared to OTP codes.

FAQs

What devices support passkeys?

Passkeys require a browser that implements the WebAuthn API; the back office checks for PublicKeyCredential and navigator.credentials support before prompting. Biometric passkeys require fingerprint/face hardware, and external hardware keys (e.g. YubiKey) are also usable. (Specific browser/OS version support is a platform/browser detail and is not-verified-here.)

Can I have multiple passkeys?

Yes. You can register multiple passkeys, each with a unique name (e.g. "MacBook Pro", "iPhone", "Office YubiKey"). The code enforces unique names per user but does not impose a hard count limit.

What happens if I lose my device with the passkey?

You can still sign in with email/password (and complete step-up with an Email or SMS OTP). Once in, go to Profile > Passkeys to delete the lost device's passkey and register a new one. Passkeys are not the account credential -- they are one of several login verification options.

Are passkeys more secure than passwords?

Passkeys use public-key cryptography where the private key never leaves the device, so they are resistant to phishing (domain-bound), credential stuffing, and server-side breaches (only the public key is stored). In this product they supplement password login as a step-up method rather than replacing the password entirely.

Can I use passkeys across different Upvendo backoffice domains?

No. Passkeys are bound to the RP ID (the backoffice hostname) they were registered on. If the backoffice URL changes, existing passkeys stop working and must be re-registered.

What authentication methods can I use to sign in?

Sign-in begins with either email + password or Google sign-in (POST /api/login/google). Google sign-in is treated as a strong factor and skips the step-up screen entirely, so passkeys are never offered on that path. On the password path: from a trusted device the password is enough; from an unrecognized device a step-up screen (AuthMethodSelection) offers Email OTP, SMS OTP (when enabled and a phone is on file), and Passkey (only if the user has a registered passkey).

How do I know which passkey was used for login?

The management list (/profile/passkeys) shows each passkey's name and created_at. The server tracks an updated_at per passkey that is refreshed on each successful login, but that field is not returned by the list endpoint, so the management UI cannot currently display "last used".

Can staff members use passkeys?

Yes. Any back-office user account can register and manage its own passkeys; they are stored per user. (Whether specific roles can reach the profile page is a permissions detail handled by the back office and is not-verified-here.)

Troubleshooting

"Invalid passkey response" error during registration

The attestation ceremony failed. Common causes: the browser is not on the backoffice domain that matches app.backoffice_url; the stored challenge no longer matches (a newer ceremony overwrote webauthn_challenge); or the response was not a valid AuthenticatorAttestationResponse. Locally, confirm app()->isLocal() is true so localhost is an allowed origin.

Passkey login fails with "User not found"

The credential ID presented did not match any user's passkeys.passkey.publicKeyCredentialId. The passkey may have been deleted server-side, or it was registered on a different domain/environment.

Registration prompt does not appear in the browser

Ensure WebAuthn is supported (the back office checks isWebAuthnSupported()) and the page is in a secure context. @simplewebauthn/browser surfaces NotSupportedError/NotAllowedError/InvalidStateError which the dialog maps to user-facing messages. Browser extensions blocking the prompt, or the user dismissing it (NotAllowedError), can also prevent the dialog. (Secure-context requirements are browser behavior and are not-verified-here.)

"Passkey with this id already exists" error

A passkey with that name already exists for the user. Choose a different name, or delete the existing one from Profile > Passkeys first.

Passkey works on one browser but not another

Passkeys are bound to the RP ID and may be synced within a platform ecosystem (e.g. iCloud Keychain, Google Password Manager). Cross-ecosystem availability is platform/browser behavior (not-verified-here); registering a passkey per device/ecosystem avoids the problem.

Passkey authentication works locally but fails in production

Verify UPVENDO_BACKOFFICE_URL (app.backoffice_url) points to the production domain. The RP ID at registration must match the RP ID at login; if the URL changed after registration, users must re-register.

Technical Details

WebAuthn Serialization

PasskeyService's constructor builds an AttestationStatementSupportManager with only NoneAttestationStatementSupport, then creates a serializer via WebauthnSerializerFactory. That serializer is used throughout the service to convert between PHP WebAuthn objects and JSON for both storage and transport. Only none attestation is supported.

Credential Storage Format

Each entry in the user's passkeys array (MongoDB) has:

  • name: human-readable identifier (string)
  • passkey: the serialized PublicKeyCredentialSource (decoded to an array; includes publicKeyCredentialId, public key, counter, etc.)
  • created_at: UTCDateTime set at registration
  • updated_at: UTCDateTime, added/refreshed on the first and each subsequent successful login (absent until first use)

webauthn_challenge on the user stores the serialized options JSON (not just raw challenge bytes) for the in-progress ceremony, and is cleared after login.

User Lookup Strategy

During login the service queries MongoDB with the nested path passkeys.passkey.publicKeyCredentialId equal to the presented credential id, so the credential itself identifies the user. retrieveByFilter(..., throw: false) returns null on no match, which the service converts into an HTTP 400.

Device Tracking Integration

PasskeyService uses DeviceTrackingTrait, which provides getClientIp(), getUserAgent(), generateDeviceFingerprint() (SHA-256 of IP + user agent), and base64url_encode/base64url_decode. The device-trust side effect (storing {fingerprint, ip, user_agent, trusted_at}) is applied in AuthService::authenticateWithPasskey when trust_device is true. Note: the WebAuthn challenge itself is generated by the private generateRandomBytes(32) in PasskeyService, not by the trait's generateChallengeToken() (which is a 64-char hex token used by the OTP/device-challenge flow).

Localization

Passkey UI strings ship as per-locale modules at src/plugins/i18n/locales/modules/<locale>/passkeys.ts. These exist for en, nl, es, pt, de, it, and fr (not just English/Dutch). Note: the management/setup/delete dialogs use these i18n strings, but the login step-up components PasskeyAuthentication.vue and AuthMethodSelection.vue use hardcoded English strings rather than tr() lookups.

Backoffice Components

  • src/pages/profile/passkeys/index.vue: route page at /profile/passkeys (named route profile-passkeys), renders PassKeys.vue with a breadcrumb
  • PassKeys.vue: the management list with name, creation date, and delete actions. (This same component also renders a separate "Trusted Devices" card backed by the back-office/trusted-devices endpoints, which is outside the scope of passkeys proper.)
  • PasskeySetupDialog.vue: registration dialog; requests the setup challenge, runs startRegistration (@simplewebauthn/browser), and submits the result
  • PasskeyDeleteDialog.vue: delete confirmation dialog
  • PasskeyAuthentication.vue: login step that auto-runs startAuthentication and emits the credential plus trustDevice
  • AuthMethodSelection.vue: the step-up screen offering Email OTP, SMS OTP, and Passkey. The Email OTP card is always rendered; the SMS card is shown only when availableMethods.sms?.available and the Passkey card only when availableMethods.passkey?.available
  • LoginForm.vue: orchestrates login -> method_selection -> (otp | passkey)

Assistant Guidance

When users ask about passkey setup, clarify that they first register a passkey from Profile > Passkeys (enter a unique name, then complete the browser prompt with biometrics or a security key). When they ask about logging in with a passkey, explain it is a step-up option: sign in with email + password, and if the device is not recognized the back office offers Passkey alongside Email/SMS OTP -- it is not a standalone passwordless login. If passkey authentication fails, check that the backoffice domain matches the RP ID (app.backoffice_url) used at registration. Suggest descriptive passkey names and remind users they can always fall back to OTP if a device is lost.

Relations

Depends On

  • Authentication System: Passkeys integrate via AuthService as a step-up option within loginWithDeviceCheck / completeLogin, alongside email and SMS OTP.
  • User Model: Passkeys live in the passkeys array and the ceremony state in the webauthn_challenge field on the User document (MongoDB).
  • WebAuthn Library: Uses web-auth/webauthn-lib for ceremony validation and credential serialization on the server, and @simplewebauthn/browser on the client.
  • Device Tracking Trait: Provides client IP, user agent, device fingerprint, and base64url helpers.

Affects

  • Login Flow: Adds Passkey as a step-up authentication option on the login page for untrusted devices.
  • User Profile: Adds the Passkeys management page (/profile/passkeys) with register / list / delete.
  • Trusted Devices: A successful passkey login with trust_device: true records a trusted device, allowing future logins from that device to skip step-up.
  • Security: Provides a phishing-resistant, domain-bound alternative to OTP during device step-up.