π WebAuthn / Passkeys¶
Passkeys let users authenticate with public-key cryptography instead of (or in addition to) a password β using Face ID / Touch ID, Windows Hello, Android biometrics, or a hardware security key (YubiKey). The private key never leaves the device; the server stores only the public key. Passkeys are phishing-resistant by design (credentials are bound to your domain) and leave nothing replayable in the database.
This library supports two integration models, both opt-in per user:
- Passwordless login β sign in with just a passkey (usernameless / discoverable).
- Second factor (2FA) β present a passkey after the password, via the post-auth Action system (like TOTP).
π Table of Contents¶
- Availability vs. Enforcement
- How It Works
- Configuration
- Routes & JSON Endpoints
- Enrollment
- Passwordless Login
- Passkey as a Second Factor
- HasWebAuthn Trait Reference
- Storage
- Frontend / JavaScript
- Security Invariants
- Testing
- Security Notes
Availability vs. Enforcement¶
Two independent axes β don't conflate them:
| Axis | Setting | Meaning |
|---|---|---|
| Availability | AuthSecurity.$webauthnEnabled (default false) |
OFF β the feature does not exist β auth()->routes() registers no WebAuthn routes and every endpoint returns 404. ON β users may enrol a passkey. |
| Enforcement | (not in v1) | Whether a passkey is required is a separate policy, intentionally out of scope for v1. Enabling availability never forces anyone. |
So: enabling the flag lets users who want a passkey configure one. It never obligates anyone.
How It Works¶
WebAuthn replaces the shared secret (a password) with an asymmetric key pair generated and held by the authenticator. Authentication is a signed challengeβresponse, so there is no replayable secret and a database breach exposes only useless public keys.
Passwordless login:
User clicks "Sign in with a passkey"
β
Server issues a random challenge (request options)
β
Browser β navigator.credentials.get() β device prompts biometric/PIN
β
Authenticator signs the challenge with the private key
β
Server verifies the signature against the stored public key,
checks origin / rpId / challenge / sign-count
β
Session created β user is logged in
A passkey verified with user verification (biometric/PIN) is already multi-factor (possession + inherence), so passwordless login completes the session directly and does not re-run the login Action pipeline.
Under the hood this mirrors the OAuth pattern: a WebAuthnManager (in src/Libraries/WebAuthn/) orchestrates the ceremonies using web-auth/webauthn-lib v5, a WebAuthnCredentialRepository maps rows β the library's CredentialRecord, and a WebAuthnController exposes JSON endpoints. Verification ends in auth()->login($user, false).
Configuration¶
Enable the feature and tune the ceremony in Config\AuthSecurity (override via setting() at runtime):
| Setting | Default | Purpose |
|---|---|---|
$webauthnEnabled |
false |
Global availability flag. |
$webauthnRelyingPartyId |
null β request host |
The rpId β the domain credentials are bound to (anti-phishing). |
$webauthnRelyingPartyName |
'Daycry Auth' |
Display name shown in the browser passkey prompt. |
$webauthnAllowedOrigins |
[] β derived from base_url() |
Origins accepted during verification (add subdomains / native-app origins). |
$webauthnUserVerification |
'preferred' |
required | preferred | discouraged. Use required for passwordless. |
$webauthnResidentKey |
'preferred' |
Discoverable credential β needed for usernameless login. |
$webauthnAttestationConveyance |
'none' |
none | indirect | direct. none is best for privacy. |
$webauthnAuthenticatorAttachment |
null |
null (both) | platform | cross-platform. |
$webauthnTimeout |
60000 |
Ceremony timeout (ms). |
$webauthnChallengeTtl |
120 |
Challenge validity in seconds (single-use). |
$webauthnMaxCredentialsPerUser |
10 |
Per-user passkey cap. |
// Recommended for true passwordless:
setting('AuthSecurity.webauthnEnabled', true);
setting('AuthSecurity.webauthnUserVerification', 'required');
setting('AuthSecurity.webauthnRelyingPartyId', 'example.com');
setting('AuthSecurity.webauthnRelyingPartyName', 'My App');
Dependency: WebAuthn requires
web-auth/webauthn-lib:^5.3. It is a normal Composer dependency of this library. Because this repo does not commitcomposer.lock, CI resolves a PHP-version-appropriate Symfony set per matrix row.
Routes & JSON Endpoints¶
Registered automatically by auth()->routes($routes) only when webauthnEnabled is true (the controller also re-checks and 404s β defense in depth):
POST webauthn/register/options # enrolment: get creation options (auth required)
POST webauthn/register/verify # enrolment: verify attestation (auth required)
POST webauthn/login/options # passwordless: get request options (public)
POST webauthn/login/verify # passwordless: verify assertion (public)
POST webauthn/2fa/options # 2FA: request options for pending user
POST webauthn/credentials/{uuid}/delete # revoke a passkey (auth required)
All endpoints return JSON {status, ...} (or {status:"error", error, message} with a 4xx code). The browser-facing ceremony uses the bare option objects; the bundled JS wraps them as {publicKey: ...} before calling navigator.credentials.
Enrollment¶
A logged-in user enrols a passkey from the security page. The bundled widget (webauthn_setup view) calls:
POST webauthn/register/options {name?}βPublicKeyCredentialCreationOptions(challenge stashed in the session, the user's existing credentials listed inexcludeCredentials).navigator.credentials.create({publicKey})β the device generates a key pair and signs the attestation.POST webauthn/register/verify {credential, name}β the attestation is verified (origin, rpId, challenge), and theCredentialRecordis persisted. Returns201 {status:"ok", credential:{uuid, name}}.
Errors: 403 (not logged in), 409 (per-user cap reached or duplicate credential), 422 (verification failed), 404 (feature disabled).
Passwordless Login¶
On the login page, a "Sign in with a passkey" button calls:
POST webauthn/login/options {email?}β request options. With no email, the flow is usernameless/discoverable (allowCredentialsempty). With an email, it is scoped to that user's credentials. Anti-enumeration: an unknown email returns well-formed options with an emptyallowCredentialsand never reveals whether the account exists.navigator.credentials.get({publicKey})β the device signs the assertion.POST webauthn/login/verify {credential}β the credential is looked up by its id, the assertion is verified (signature, challenge, origin, rpId, user-verification, sign-count anti-clone), the counter is persisted, and the session is established. Returns200 {status:"ok", redirect}.
Passkey as a Second Factor¶
Set the login action in Config\Auth:
After a successful password login, Webauthn2FA:
- Skips silently if the user has no registered passkey (
createIdentity()returns''). - Otherwise inserts a pending marker and shows the
webauthn_2fa_verifyview, which requests an assertion scoped to the pending user and posts it to the Action verify endpoint. - Applies the same
UserLockoutManagerbrute-force lockout as password login, and enforces that the credential belongs to the pending user.
Only one
loginaction is supported at a time, soWebauthn2FAandTotp2FAare mutually exclusive as the login second factor in v1.
HasWebAuthn Trait Reference¶
Mixed into the User entity:
| Method | Returns | Description |
|---|---|---|
webAuthnCredentials() |
list<WebAuthnCredential> |
The user's active (non-revoked) passkeys. |
hasWebAuthnCredentials() |
bool |
Whether the user has β₯1 active passkey. |
revokeWebAuthnCredential(string $uuid) |
bool |
Soft-revokes a passkey the user owns. |
$user = auth()->user();
if ($user->hasWebAuthnCredentials()) {
foreach ($user->webAuthnCredentials() as $credential) {
echo $credential->name, ' β last used: ', $credential->last_used_at;
}
}
Storage¶
Passkeys live in a dedicated auth_webauthn_credentials table (configurable via Config\Auth::$tables['webauthn_credentials']), following the same dedicated-table pattern as device sessions and TOTP backup codes:
| Column | Notes |
|---|---|
uuid |
UUID v7, external reference |
user_id |
FK β users (CASCADE) |
credential_id |
base64url credential id, unique β the assertion lookup key |
credential |
the serialized Webauthn\CredentialRecord (source of truth for all crypto) |
user_handle |
opaque WebAuthn handle = users.uuid (non-PII) |
name, sign_count, transports, aaguid |
denormalized for display / the anti-clone counter |
last_used_at, revoked_at |
usage + soft-revocation |
The WebAuthnManager, WebAuthnCredentialRepository, serializer and validators are resolvable, overridable services: service('webAuthnManager'), service('webAuthnCredentialRepository'), service('webAuthnSerializer'), service('webAuthnAttestationValidator'), service('webAuthnAssertionValidator').
Frontend / JavaScript¶
The library ships reference views (webauthn_setup, webauthn_2fa_verify) and a shared vanilla-JS partial (_webauthn_js) that handles the fiddly base64url β ArrayBuffer conversions and the ceremony round-trips. They are overridable via Config\Auth::$views, and an SPA can ignore the views entirely and call the JSON endpoints directly. The credential list renders inside the existing security_overview page.
Security Invariants¶
Every ceremony enforces (each has a dedicated negative test in tests/WebAuthn/WebAuthnSecurityTest.php):
- Server-generated challenge (β₯16 bytes), single-use, TTL-bounded, bound to ceremony type (and user for register/2FA).
- Origin binding β
clientDataJSON.originmust be an allowed origin (anti-phishing). - rpId binding β
rpIdHashmust match the configuredrpId. - User verification enforced per
webauthnUserVerification. - Signature verified against the stored COSE public key.
- Anti-clone β the sign-count must advance; regressions are rejected (enforced by the library and the persisted counter).
- userHandle must match the credential's stored handle.
- Ownership β in 2FA / delete, the credential must belong to the (pending/logged-in) user.
- Revoked credentials are excluded from lookup and
allowCredentials. - Anti-enumeration β
login/optionsnever reveals whether an email exists. - Lockout β failed 2FA attempts feed the same per-user lockout as password failures.
- CSRF β CI4 CSRF applies to the POST endpoints (the ceremony is additionally CSRF-resistant via the challenge).
Testing¶
WebAuthn ceremonies normally need real hardware. This library ships a test-only software authenticator β Tests\Support\WebAuthn\VirtualAuthenticator β that produces real attestation/assertion responses (ES256, hand-built CBOR/COSE) which the genuine web-auth/webauthn-lib validators accept. Tests drive full ceremonies end-to-end without hardware and without brittle static fixtures:
$authn = new VirtualAuthenticator('example.com', 'https://example.com');
$options = service('webAuthnManager')->startRegistration($user, 'My Laptop');
$entity = service('webAuthnManager')->finishRegistration($user, $authn->register(json_encode($options)));
The library (v5.3) emits an
E_USER_DEPRECATEDfor the still-required relying-party name. Because the test suite runs withCODEIGNITER_SCREAM_DEPRECATIONS=1, WebAuthn tests use theTests\Support\WebAuthn\SuppressesWebauthnDeprecationstrait to silence the library's own internal deprecations.
Security Notes¶
- A passkey verified with user verification is multi-factor on its own; prefer
webauthnUserVerification = 'required'for passwordless flows. - Set an explicit
webauthnRelyingPartyId(andwebauthnAllowedOriginsfor subdomains) in production β never rely on the request host for the rpId across multiple domains. - The password remains a fallback unless you deliberately remove it; v1 does not implement passkey-only accounts.
- Keep
webauthnAttestationConveyance = 'none'unless you specifically need authenticator-model attestation (which carries privacy and complexity costs).