Appearance
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
passkeysarray containing a human-readablename, the serializedpasskey(aPublicKeyCredentialSource, which includes the credential ID and public key), acreated_attimestamp (a MongoDBUTCDateTimeset at registration), and, after the first successful login with it, anupdated_attimestamp. - 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 withAuthenticatorAssertionResponseValidator. 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 ofconfig('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 privategenerateRandomBytes(32)helper (which callsrandom_bytes(32)). It then builds the fullPublicKeyCredentialCreationOptions/PublicKeyCredentialRequestOptions, serializes the whole options object to JSON, and stores that JSON string on the user'swebauthn_challengefield -- 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::authenticateWithPasskeyrecords a trusted device entry ({fingerprint, ip, user_agent, trusted_at}, where the fingerprint = SHA-256 ofIP|user-agentandtrusted_atis a Unixtime()integer) on the user so future logins from that device skip the step-up screen. Device trust expires after 30 days (Usermodel). In the back office thePasskeyAuthenticationcomponent defaultstrustDevicetotrue(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.phpunder/back-office/passkeys(inside theauth+type:backoffice+tenant:backoffice+check-user-activitymiddleware group); guest login routes inroutes/api/guest.phpunder/back-office(guestmiddleware) - 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
PublicKeyCredentialCreationOptionsJSON (returned as a rawResponse)
Register a Passkey
Completes the registration ceremony. Receives the attestation response (passkey, a JSON string) plus a name. The server:
- Deserializes the credential with the WebAuthn serializer.
- Rejects (HTTP 400, "Passkey with this id already exists") if an existing passkey already uses that
name. - Verifies the response is an
AuthenticatorAttestationResponse(else 400, "Invalid passkey response"). - Builds a
CeremonyStepManagerFactory; in a local environment (app()->isLocal()) it addslocalhostas an allowed origin. - Validates the attestation against the stored options and the backoffice host. Any failure returns 400, "Invalid passkey response".
- Appends
{name, passkey (serialized PublicKeyCredentialSource), created_at (UTCDateTime)}to the user'spasskeysarray 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
PublicKeyCredentialRequestOptionsJSON (rawResponse)
Authenticate with Passkey
Completes the login assertion ceremony, then completes login. PasskeyService::authenticateWithPasskey:
- Deserializes the credential and confirms it is an
AuthenticatorAssertionResponse(else 400). - Looks up the user by matching the credential
idagainst the nested fieldpasskeys.passkey.publicKeyCredentialId(retrieveByFilter(..., throw: false)); 400 "User not found" if none. - Finds the matching passkey entry (400 "Passkey not found" if missing) and deserializes its
PublicKeyCredentialSource. - Validates the assertion against the stored challenge options, the passkey source, the backoffice host, and
userHandle= user ID (failure -> 400 "Invalid passkey response"). - Re-serializes the updated source (refreshed signature counter), keeps the original
created_at, and setsupdated_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
| Field | ID | Type | Required | Validation |
|---|---|---|---|---|
| Passkey Name | name | String | Yes (setup / delete) | required|string|max:255 (setup); required|string (delete) |
| Passkey Credential | passkey | JSON string | Yes (setup / auth) | required|json -- serialized WebAuthn credential |
| Trust Device | trust_device | Boolean | No (auth only) | boolean -- adds the device to trusted devices on success |
email | String | Yes (login challenge) | required|string -- email used to look up the user |
Business Rules
- A user cannot register two passkeys with the same
name.setupPasskeyiterates 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, defaulthttp://localhost:5174). Passkeys are domain-bound and will not work if the backoffice host changes. - In a local environment (
app()->isLocal()), theCeremonyStepManagerFactoryaddslocalhostas 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 theweb-auth/webauthn-libvalidator -- not-verified-here whether a counter regression hard-fails.) - The
webauthn_challengefield 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, withthrow: falseto return null (then a 400) rather than throw. - Only
noneattestation 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.
loginWithDeviceCheckreturnsstatus: '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 viaPOST /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,
loginWithDeviceCheckreturns anavailable_methodspayload that always lists apasskeymethod, but itsavailableflag is set tohasPasskeys($user)(true only when the user has at least one registered passkey). The back office only renders the Passkey option whenavailableMethods.passkey?.availableis 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 serializedPublicKeyCredentialSource(decoded to an array; includespublicKeyCredentialId, public key, counter, etc.)created_at:UTCDateTimeset at registrationupdated_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 routeprofile-passkeys), rendersPassKeys.vuewith a breadcrumbPassKeys.vue: the management list with name, creation date, and delete actions. (This same component also renders a separate "Trusted Devices" card backed by theback-office/trusted-devicesendpoints, which is outside the scope of passkeys proper.)PasskeySetupDialog.vue: registration dialog; requests the setup challenge, runsstartRegistration(@simplewebauthn/browser), and submits the resultPasskeyDeleteDialog.vue: delete confirmation dialogPasskeyAuthentication.vue: login step that auto-runsstartAuthenticationand emits the credential plustrustDeviceAuthMethodSelection.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 whenavailableMethods.sms?.availableand the Passkey card only whenavailableMethods.passkey?.availableLoginForm.vue: orchestrateslogin -> 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
AuthServiceas a step-up option withinloginWithDeviceCheck/completeLogin, alongside email and SMS OTP. - User Model: Passkeys live in the
passkeysarray and the ceremony state in thewebauthn_challengefield on theUserdocument (MongoDB). - WebAuthn Library: Uses
web-auth/webauthn-libfor ceremony validation and credential serialization on the server, and@simplewebauthn/browseron 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: truerecords 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.