Passkeys (WebAuthn)
Passkeys (WebAuthn)
Passkeys let users sign in with their device's screen lock (Touch ID, Face ID, Windows Hello, an Android phone, a security key) instead of a password. Hazaar implements the server side of Web Authentication Level 3 in Hazaar\Auth\WebAuthn, with no extra dependencies, and ships a small browser helper, libs/js/passkey.js.
Passkeys sit alongside the rest of the authentication system: they use the same user provider, the same session backend and transport, and the same LoginSucceeded event. A typical setup keeps password sign-in and lets signed-in users add passkeys.
How it fits together
RelyingPartyruns the two WebAuthn ceremonies (registering a passkey, signing in with one). It only verifies; it never starts a session.PasskeyAuthenticatorturns a verified sign-in into the user'sIdentity, checking the user still exists in your user provider.Adapter::login()starts the session, exactly as a password sign-in does.
Configuration
Add a webauthn section to auth in configs/app.php:
'auth' => [
// ... your adapter or provider, backend and transport ...
'webauthn' => [
'rpId' => 'example.com',
'rpName' => 'Example',
'origins' => ['https://example.com', 'https://www.example.com'],
],
],rpId and origins are required and are never worked out from the request: the Host header is controlled by the client. A misconfiguration is reported as an InvalidConfiguration exception when the relying party is created.
| Option | Default | Description |
|---|---|---|
rpId | (required) | The domain passkeys are bound to. Passkeys registered for example.com work on it and its subdomains. |
rpName | the rpId | The name browsers show the user. |
origins | (required) | The exact origins ceremonies may come from: scheme, host and any port, no path. Each must be https (or http://localhost for development) and on the rpId or a subdomain of it. |
userVerification | required | required, preferred or discouraged. With required, every ceremony must verify the user (PIN, biometrics), not just their presence. |
residentKey | required | Whether passkeys must be discoverable, which is what allows signing in without typing a username. |
algorithms | [-8, -7, -257] | COSE algorithms offered, in order of preference: EdDSA, ES256, RS256. These are the only ones supported. |
timeout | 300 | Seconds a ceremony may take. Challenges expire after this. |
signCountPolicy | reject | What to do when an authenticator's signature counter goes backwards (a possible clone): reject, or warn to log and allow. |
hints | [] | Browser UI hints: security-key, client-device, hybrid. |
allowCrossOrigin | false | Allow ceremonies inside cross-origin iframes. Needs topOrigins. |
topOrigins | [] | With allowCrossOrigin, the pages a cross-origin iframe may be embedded in. A cross-origin ceremony without an allowed topOrigin is refused. |
store | the File store | Where passkeys are kept: a Hazaar\Auth\Interface\PasskeyCredentialStore class or instance. See Storing passkeys. |
storeOptions | [] | Options for store. |
challengeCache | the app's default cache | backend and options for the cache challenges are kept in, as for Hazaar\Cache\Adapter. |
Build the relying party from this config with RelyingParty::fromConfig(), or pass the array to new RelyingParty($config).
Storing passkeys
The default store, Hazaar\Auth\Store\File\PasskeyCredentialStore, keeps passkeys in a JSON file in the runtime directory (storeOptions.file, default auth_passkeys.json). It suits development and small single-server apps.
For anything else, use the database store:
'webauthn' => [
// ...
'store' => \Hazaar\Auth\Store\DBI\PasskeyCredentialStore::class,
'storeOptions' => ['table' => 'passkeys'], // and 'dbi' => <config name or array> for another database
],Create the table yourself, typically in a dbitool migration. PasskeyCredentialStore::SCHEMA holds portable SQL (SQLite, PostgreSQL, MySQL) for it:
CREATE TABLE passkeys (
id VARCHAR(1400) NOT NULL PRIMARY KEY,
identity VARCHAR(255) NOT NULL,
user_handle VARCHAR(100) NOT NULL,
public_key TEXT NOT NULL,
algorithm INTEGER NOT NULL,
sign_count BIGINT NOT NULL DEFAULT 0,
transports TEXT,
aaguid CHAR(32),
backup_eligible INTEGER NOT NULL DEFAULT 0,
backup_state INTEGER NOT NULL DEFAULT 0,
uv_initialized INTEGER NOT NULL DEFAULT 0,
name VARCHAR(255),
created_at BIGINT NOT NULL,
last_used_at BIGINT
);
CREATE INDEX passkeys_identity ON passkeys (identity);
CREATE INDEX passkeys_user_handle ON passkeys (user_handle);Binary values are stored as base64url text and flags as 0/1, so no database-specific types are needed. Each user gets one random user handle (never their username), created with their first passkey and stored with each of their passkeys.
Challenges are kept in a cache and redeemed exactly once, even when the same response arrives twice at the same moment. Use a cache shared by all your servers (Redis, Memcached, the database) if you run more than one.
The endpoints
Each ceremony takes two requests: one for the options the browser needs, and one to verify the browser's answer. So an app exposes four endpoints:
<?php
namespace App\Controllers;
use Hazaar\Auth\Authenticator\PasskeyAuthenticator;
use Hazaar\Auth\Exception\AuthenticationFailed;
use Hazaar\Auth\WebAuthn\Exception\WebAuthnException;
use Hazaar\Auth\WebAuthn\RelyingParty;
use Hazaar\Controller\Basic;
use Hazaar\Controller\Response\JSON;
class Passkey extends Basic
{
// POST /passkey/register/options (signed in)
public function registerOptions(): JSON
{
return new JSON(RelyingParty::fromConfig()->startRegistration($this->session->get('identity')));
}
// POST /passkey/register/verify (signed in), body: the browser's credential JSON
public function registerVerify(): JSON
{
$json = $this->request->getJSONBody(true);
try {
$passkey = RelyingParty::fromConfig()->finishRegistration($json, $this->session->get('identity'), $json['name'] ?? null);
} catch (WebAuthnException $e) {
return new JSON(['error' => 'Could not register the passkey'], 400);
}
return new JSON(['ok' => true, 'name' => $passkey->name]);
}
// POST /passkey/login/options, body: {} or {"identity": "..."}
public function loginOptions(): JSON
{
return new JSON(RelyingParty::fromConfig()->startAuthentication($this->request->getJSONBody(true)['identity'] ?? null));
}
// POST /passkey/login/verify, body: the browser's credential JSON
public function loginVerify(): JSON
{
$passkeys = new PasskeyAuthenticator(RelyingParty::fromConfig(), $this->session->getProvider());
try {
$result = $this->session->login($passkeys->verify($this->request->getJSONBody(true)));
} catch (AuthenticationFailed $e) {
return new JSON(['error' => $e->getMessage()], 401);
}
return new JSON(['ok' => true, 'identity' => $result->identity]);
}
}// route.php
Router::post('/passkey/register/options', [Passkey::class, 'registerOptions'])->auth();
Router::post('/passkey/register/verify', [Passkey::class, 'registerVerify'])->auth();
Router::post('/passkey/login/options', [Passkey::class, 'loginOptions'])->session();
Router::post('/passkey/login/verify', [Passkey::class, 'loginVerify'])->session();Notes:
- The registration endpoints use
->auth(): a passkey is always added to an account someone is already signed in to.finishRegistration()also checks the challenge was issued for that same user. startAuthentication()without an identity lets the browser offer every passkey for the site, so users sign in without typing a username. With an identity, only that user's passkeys are allowed.PasskeyAuthenticatorlogs the exact reason a sign-in failed (as a warning, throughHazaar\Log) but throws anAuthenticationFailedwhose message never says why, so it is safe to show the client.Adapter::login()firesauthenticationSuccess()and theLoginSucceededevent, like a password sign-in.
In the browser
libs/js/passkey.js is an ES module that calls these endpoints and the browser's WebAuthn API:
import { register, login, autofill, PasskeyError } from '/js/passkey.js';
// Add a passkey for the signed-in user.
await register('/passkey/register/options', '/passkey/register/verify', { name: 'My laptop' });
// Sign in: without a username the browser offers every passkey for the site.
try {
const result = await login('/passkey/login/options', '/passkey/login/verify');
} catch (e) {
if (e instanceof PasskeyError && e.code === 'cancelled') {
// The user closed the prompt (or it timed out).
}
}PasskeyError.code is one of unsupported, cancelled, exists (this authenticator already has a passkey for the account), aborted, security (the page's origin doesn't match the RP ID) or server (an endpoint returned an error; see status and detail).
The helper uses the browser's WebAuthn JSON helpers (PublicKeyCredential.parseCreationOptionsFromJSON() and friends, in Chrome 129+, Firefox 119+ and Safari 18.4+) and does the conversion itself on older browsers.
Autofill
Browsers can offer passkeys in a username field's autofill, so users who have one pick it without a separate button. Mark the field and start autofill() when the page loads:
<input name="username" autocomplete="username webauthn">
<input name="password" type="password" autocomplete="current-password">
<script type="module">
import { autofill } from '/js/passkey.js';
const controller = new AbortController();
autofill('/passkey/login/options', '/passkey/login/verify', { signal: controller.signal })
.then((result) => { if (result?.ok) location.href = '/'; });
// If the user signs in with a password instead, abort the pending passkey request.
document.querySelector('form').addEventListener('submit', () => controller.abort());
</script>autofill() resolves to null on browsers without autofill support, so a page can fall back to a "Sign in with a passkey" button that calls login().
Managing passkeys
Users should be able to see and remove their passkeys:
$rp = RelyingParty::fromConfig();
$identity = $this->session->get('identity');
foreach ($rp->listCredentials($identity) as $passkey) {
// $passkey->id (raw bytes; base64url-encode it for URLs), ->name, ->createdAt, ->lastUsedAt,
// ->backupEligible / ->backupState (whether it syncs between the user's devices)
}
$rp->renameCredential($identity, $credentialId, 'Work laptop');
$rp->deleteCredential($identity, $credentialId);Renaming and deleting only ever touch the given user's own passkeys.
From the command line:
bin/hazaar user passkey list alice
bin/hazaar user passkey del alice <credential-id>Security notes
- Origins are an allow-list. A passkey signs the origin it was used on, and anything not in
originsis refused, which is what stops a phishing site from using a passkey made for your site. - Keep a way back in. A user who loses their only passkey needs another way to sign in: keep passwords, encourage a second passkey, or have a recovery process. Synced passkeys (
backupState) survive losing one device. - Ask for the password again before adding a passkey. Registration needs a signed-in user; for stronger protection, require a recent password check (
$this->session->check($identity, $password)) first. - User verification is required by default. Lower
userVerificationonly if you accept "someone touched the key" in place of "the user unlocked it". - Sign counters. Authenticators that keep a counter increase it with every signature. A counter that goes backwards suggests a cloned authenticator, and is refused unless
signCountPolicyiswarn. Synced passkeys usually report0, which is always accepted. - Attestation is not checked. Hazaar requests
noneand accepts any authenticator. Restricting sign-in to particular authenticator models (attestation trust and the FIDO Metadata Service) isn't supported yet. - Related origins (
/.well-known/webauthn, for using one set of passkeys across different domains) aren't supported yet.
Testing
tests/e2e/passkey runs registration and sign-in in a real Chromium with a virtual authenticator, against a small app built the way this page describes. See its README.