Search by

cloudrepublic / shield-passkey-mfa

cloudrepublic

Passkey (WebAuthn) MFA action for CodeIgniter Shield, using web-auth/webauthn-lib for the actual cryptography.

Package info

github.com/CloudRepublic-io/shield-passkey-mfa

Homepage

Issues

pkg:composer/cloudrepublic/shield-passkey-mfa

Statistics

Installs: 4

Dependents: 0

Suggesters: 1

Stars: 0

v1.0.1 2026-09-28 22:59 UTC

This package is auto-updated.

Last update: 2026-09-28 23:05:38 UTC


README

A drop-in set of Shield Actions, controllers, and views that add passkey (WebAuthn/FIDO2) based MFA to a CodeIgniter Shield app - the actual cryptography is entirely delegated to web-auth/webauthn-lib, not reimplemented here.

Built as the passkey counterpart to the shield-totp-mfa package in this same series, reusing every architectural lesson learned building that one:

  • PasskeyMfa - the 'login' action. Verification only.
  • PasskeyActivator - the 'register' action. Optional setup during signup, with a "skip for now" link.
  • PasskeySettingsController - self-service add/rename/remove, any time, for existing users.

Unlike a single TOTP secret, a user can register several passkeys (phone, laptop, a hardware key as backup), so credentials are stored in their own table (auth_passkey_credentials) - a genuine list, closer in shape to shield-totp-mfa's remembered-devices table than to its single-secret model.

Deliberately out of scope: "remember this device" and "step-up auth for sensitive pages" aren't reimplemented here - both could be added following the exact same pattern as shield-totp-mfa's RememberedDeviceModel/RequireFreshTotp filter, if wanted. This package's job is the actually-new part: the WebAuthn integration itself.

Requirements

  • PHP 8.2 or later
  • CodeIgniter 4.6 or later
  • CodeIgniter Shield 1.4 or later
  • web-auth/webauthn-lib 5.3 or later (installed automatically by Composer). 5.3 is the first version with Webauthn\CredentialRecord, which the synced-passkey counter fix below depends on; on 5.1 or 5.2 the package fails with a fatal error.

Tested on CodeIgniter 4.6 and 4.7, up to PHP 8.5.

Read this before anything else: version sensitivity

web-auth/webauthn-lib has changed its core setup API significantly across major versions - PSR-7-based request handling and a PublicKeyCredentialLoader class in v3.x, deprecated in favor of a Symfony Serializer approach in v4.8, and removed entirely in v5.0 in favor of Webauthn\CeremonyStep\CeremonyStepManagerFactory. This package was written against the confirmed-current v5.x shape (verified against the library's own current documentation, not assumed from memory), all isolated into one file: src/Libraries/WebauthnFactory.php. That file's own doc comment has the full explanation and a checklist - read it before relying on this package, especially if composer show web-auth/webauthn-lib shows something other than 5.3 or a later 5.x version (composer.json requires ^5.3).

A mismatch here fails loudly (a PHP TypeError), not silently - which is the good news. The more important thing this can't protect you from automatically: actually run a real registration and a real login through a real browser and authenticator before trusting this in production. This is the one package in this series where "the code looks right" is meaningfully less reassuring than usual, given how cryptography-heavy the actual verification is - there's no equivalent here to TOTP's RFC 6238 test vectors to check the math against.

A deprecation warning - left in place deliberately, after a real mistake

If your log shows [DEPRECATED] Since web-auth/webauthn-lib 5.3.0: Setting the "name" field on "PublicKeyCredentialRpEntity" is deprecated during registration (PasskeySettingsController::enroll()/confirm(), or PasskeyActivator), this is expected and harmless on web-auth/webauthn-lib 5.3.0+ - confirmed via the library's own official migration docs (webauthn-doc.spomky-labs.com/migration/from-v5.x-to-v6.0): the Relying Party entity's name property was deprecated in v5.3.0 and will be removed entirely in v6.0, since "the Relying Party name is no longer required" per the WebAuthn Level 3 spec.

A real mistake happened here, and it's worth being direct about it. An earlier version of this README claimed passing null instead of Config\PasskeyMfa::$rpName to this entity "fixed" the warning - but that assumed the parameter had already been widened to accept null at the same time it was deprecated. It hadn't: a real app running this package's own declared, supported constraint (composer.json then said web-auth/webauthn-lib ^5.1; it's now ^5.3) hit an immediate fatal TypeError - Argument #1 ($name) must be of type string, null given - the moment that "fix" shipped. Deprecating a feature and changing its type signature are two distinct events that don't necessarily happen together; marking something deprecated typically means "still works, but discouraged," not "already accepts what a future version will require."

This is reverted. beginRegistration() passes the actual string value again, which is safe across this package's entire declared ^5.3 range regardless of which specific patch version is installed - a non-null string satisfies both a string and a ?string parameter type. The deprecation warning itself is real but harmless (registration and login both work correctly either way, on every version in the supported range) - living with a harmless log warning is the correct trade-off here, not risking a fatal error in exchange for silencing it. See PasskeyIdentityStore::beginRegistration()'s own doc comment for the full account. If you were chasing a different, actually-broken symptom and found this warning in your log at the same time, it's unrelated - keep looking at the specific flow that's actually failing.

Every synced passkey login used to fail verification - fixed

Fixed in the current version - update immediately if you're on an older copy; this affects every login, not an edge case. An earlier version of WebauthnFactory used web-auth/webauthn-lib's own default signature counter checker (Webauthn\Counter\ThrowExceptionIfInvalid) unmodified. That default hard-rejects a login the moment the signature counter fails to strictly increase between attempts.

Passkeys synced via Chrome's or Edge's or Safari's own built-in, cloud-based passkey managers (iCloud Keychain, Google Password Manager, Windows Hello's own cloud sync) report a signature counter of 0 on every single authentication, permanently - this is intentional, spec-compliant behavior, not a bug in those platforms. The W3C WebAuthn specification itself says explicitly: if both the stored and returned counters are 0, the authenticator does not support a counter, and the check should be skipped entirely - a strictly-increasing counter has no coherent meaning for a credential that can legitimately be used from several independently-synced devices at once, which is the whole point of a synced passkey. The library's own default checker doesn't apply that exception, so stored=0, new=0 was treated as "counter did not increase" and rejected - meaning every login with a synced passkey failed verification, unconditionally, regardless of how many credentials a user had registered or which device/browser they used. From the user's side, this looked exactly like: the browser's own passkey prompt appears and is completed successfully, but the page just reloads back to the login screen, silently.

WebauthnFactory::assertionValidator() now wires in SyncedPasskeyCounterChecker instead - implementing the W3C's own guidance directly: both counters at 0 is accepted (authenticator doesn't support one), a genuinely non-zero counter is still required to strictly increase (preserving the counter's real purpose - detecting a cloned hardware security key, for the credentials where that check actually means something). See that class's own doc comment for the full detail, and SyncedPasskeyCounterCheckerTest for coverage of this logic in isolation (this specific piece, unlike most of this package, has no cryptography dependency, so it's fully testable without a real browser).

A follow-up fix, confirmed against a real fatal error on a real app: the first version of SyncedPasskeyCounterChecker type-hinted Webauthn\PublicKeyCredentialSource and called ->getCounter(), which caused an immediate Fatal error: Declaration ... must be compatible with Webauthn\Counter\CounterChecker::check(Webauthn\CredentialRecord $credentialRecord, int $currentCounter): void on any installed version where the interface itself expects the newer type. Confirmed via web-auth/webauthn-lib's own official documentation: PublicKeyCredentialSource was renamed to CredentialRecord in v5.3.0 (the old name still exists, as a deprecated subclass, for backward compatibility - removed entirely in v6.0), and the counter is read as a direct property (->counter), not a method call. Both are fixed now - see that class's own doc comment for the full account.

Resolved - the real cause turned out to be unrelated to multiple passkeys, or to PasskeyIdentityStore/PasskeyMfa at all. A real user's diagnostic session traced this to Config\PasskeyMfa::$enableEarlyAuthentication (an optional login-page-blur feature that has since been removed and replaced by passkey autofill - see "Optional: passkey sign-in on the login page" below) being enabled at the same time. verify() itself was confirmed working correctly via log_message() output in one test - but a later test on the same setup showed nothing logged at all, which turned out to mean something else entirely was intercepting the request before it ever reached this package's own PHP code.

The actual bug, confirmed against CodeIgniter's own documentation: CI4's CSRF protection regenerates the token after every single request by default (Config\Security::$regenerate). PasskeyEarlyAuthController::options()

  • fired the moment a visitor blurs the email field, before they've done anything else - is itself a POST request, so by the time its response comes back, the token has already changed. The later verify() call (and, separately, the page's own normal password-login form, if a visitor has no passkey and falls back to typing their password) would then submit with a now-stale token and get silently rejected by CI4's own CSRF filter before ever reaching a controller. A CSRF rejection returns an HTML error page, not JSON - calling .json() on that throws, which this feature's own deliberately-silent error handling swallows completely (by design, so a genuine WebAuthn failure never blocks the form) - meaning this failed with zero visibility anywhere: no server-side log (the request never reached PHP code that could log anything) and no visible client-side error either.

Both PasskeyEarlyAuthController::options() and ::verify() were changed to return the current token (csrf_token()/csrf_hash() - CI4's own "always available" functions for exactly this) in their JSON response; passkey-login.js (the login page's reference JS - see "Optional: passkey sign-in on the login page" below) updates both its own tracked copy and the page's actual hidden CSRF field after every response, so its next call - and any fallback to the normal password form - always submits with a valid, current token. See the "CSRF TOKEN HANDLING" section of passkey-login.js's own header comment for how the current script handles this.

If you never used that email-blur feature, this specific bug never applied to you, and the original "login failing with multiple passkeys" report was most likely this same issue coincidentally surfacing on whichever specific setup was being tested at the time, not something tied to credential count. The diagnostic logging added while chasing this (log_message() calls, and PasskeyIdentityStore::$lastFailureReason) is left in place - it's genuinely useful defensive instrumentation regardless of this specific resolved bug, not something that needs reverting.

The two gotchas that will bite you before anything else does

  1. $rpId must be your app's real domain, exactly. No scheme (https://), no port, no trailing slash - just the domain (example.com), or a registrable parent of the domain serving your app (example.com also covers app.example.com). Get this wrong and every ceremony fails outright, not gracefully - the browser enforces this strictly as a core WebAuthn security property, it isn't just a label you're free to make up.

  2. WebAuthn requires either HTTPS or localhost, with no exceptions. Testing over plain HTTP on anything other than localhost itself (including 127.0.0.1, and including a .local or .test domain some other package in this series used for local development) will not work - the browser refuses to run WebAuthn ceremonies at all outside a secure context. Use a real localhost URL, or set up HTTPS (even a self-signed cert) for local testing on any other hostname.

A passkey created on one device doesn't automatically work everywhere

This isn't a bug in this package - it's a well-documented, industry-wide property of how passkeys actually work, and it's confirmed to catch real users in real deployments, so it's worth understanding before anyone hits it in production.

Passkey syncing depends entirely on the platform ecosystem it was created in - Apple's iCloud Keychain, Google Password Manager, and Microsoft's own passkey provider all sync within themselves, but not across each other. A passkey created in Chrome on a Mac (synced via iCloud Keychain or Google Password Manager) will not simply appear when that same person opens Edge on a Windows machine - that's a different platform ecosystem entirely, with no passkey registered there at all. If a user's only enrolled MFA method is a passkey, and they're on a device/browser combination that was never used to register one, they can be genuinely locked out - unable to complete their existing challenge, and (per WebAuthn's own security model) unable to add a new passkey without first being logged in.

The workaround, today

  1. On whichever device/browser combination does have a working passkey (or any other enrolled MFA method), log in and switch your 2FA method away from passkey - if you're using shield-mfa-dispatcher, its settings page lets you do this; see that package's README for switching methods programmatically if you're not using the dispatcher.
  2. On the new device/browser combination, log in using that other method, then visit account/passkeys (this package's own settings page - see "Self-service enable/disable" earlier in this README) and add a passkey from there. That device/browser now has its own, independent passkey registered, alongside whichever one(s) already existed.

The more robust fix, not yet built

Every source on passkey deployment converges on the same real answer to this: recovery codes - single-use backup codes generated at enrollment time, method-agnostic, specifically for "I have no access to my usual method right now." This is a genuinely substantial, separate feature (secure generation, hashed storage, single-use validation, a UI to display/download/regenerate codes, and a "use a recovery code instead" link on the challenge page) that doesn't exist in this package - or anywhere in this series - yet. If losing access to your only enrolled method is a real risk for your users, plan around the workaround above until that exists, and consider encouraging users to enroll a passkey on more than one device from the start (this package's own settings page already supports multiple credentials per user - account/passkeys isn't limited to one).

What's in the box

src/
  Assets/
    passkey-login.js                     <- reference JS for the optional login-page passkey
                                            sign-in (autofill and/or button) - not auto-loaded
                                            (see "Optional: passkey sign-in on the login page")
  Authentication/Actions/
    PasskeyMfa.php                       <- 'login' action: verification only
    PasskeyActivator.php                 <- 'register' action: optional setup at signup
  Commands/Setup.php                     <- `php spark passkey-mfa:setup`
  Config/PasskeyMfa.php                  <- RP name/ID, challenge TTL, view overrides
  Controllers/
    PasskeyActivatorController.php       <- handles PasskeyActivator's "skip for now" link
    PasskeySettingsController.php        <- self-service add/rename/remove
    PasskeyStepUpController.php          <- step-up challenge page
    PasskeyDiscoverableAuthController.php <- optional login-page passkey sign-in endpoints
                                             (autofill and the "Login with a passkey" button)
  Database/Migrations/..._CreateAuthPasskeyCredentials.php
  Filters/RequireFreshPasskey.php        <- step-up auth filter for sensitive routes
  Language/en/PasskeyMfa.php
  Libraries/
    Base64Url.php                        <- RFC 4648 base64url, self-contained
    WebauthnFactory.php                  <- builds the webauthn-lib services - THE version-sensitive file
    SyncedPasskeyCounterChecker.php       <- fixes a confirmed bug in the library's own default
                                             signature counter check for synced passkeys
    PasskeyIdentityStore.php             <- shared registration/verification orchestration
    CompletesPendingAction.php           <- shared "finish this pending action" trait
    CompletesEarlyLogin.php              <- login completion for passkey sign-in on the
                                             login page (no password involved)
    DiagnosticLog.php                    <- gates all diagnostic log_message() calls in
                                             this package to a development environment only
  Models/PasskeyCredentialModel.php
  Views/
    passkey_activator_enroll.php         <- registration ceremony + skip (registration)
    passkey_mfa_verify.php               <- authentication ceremony (login)
    passkey_settings_index.php           <- list/rename/remove
    passkey_settings_enroll.php          <- registration ceremony (self-service)
    passkey_step_up.php                  <- authentication ceremony (step-up)
routes-snippet.php                       <- routes to add by hand

Installation

Option A - via Composer (recommended)

  1. composer require cloudrepublic/shield-passkey-mfa web-auth/webauthn-lib.

  2. Run the setup command - publishes Config/PasskeyMfa.php and Language/en/PasskeyMfa.php into your app:

    php spark passkey-mfa:setup
    

    The migration is not copied anywhere - see step 4.

Option B - manual drop-in

  1. Copy src/ into your app (e.g. app/ThirdParty/PasskeyMfa/src), register the PasskeyMfa namespace in app/Config/Autoload.php, and separately composer require web-auth/webauthn-lib (this one genuinely needs Composer either way - it isn't something reasonable to vendor by hand).
  2. Copy Config/PasskeyMfa.php to app/Config/PasskeyMfa.php, and Language/en/PasskeyMfa.php to app/Language/en/PasskeyMfa.php.

Either way, finish with these

  1. Set $rpName and $rpId in app/Config/PasskeyMfa.php (or via .env) - see the gotchas above for $rpId specifically.

  2. Run the migration - no copying needed, exactly like shield-totp-mfa's migration (see that package's README for the full story on why copying it instead causes real problems):

    php spark migrate --all
    

    (or php spark migrate -n PasskeyMfa to target just this package).

  3. Register the action(s) in app/Config/Auth.php. Login verification is required; registration-time setup is optional:

    public array $actions = [
        'register' => \PasskeyMfa\Authentication\Actions\PasskeyActivator::class, // optional
        'login'    => \PasskeyMfa\Authentication\Actions\PasskeyMfa::class,
    ];

    If you don't want a passkey step at signup, leave 'register' => null.

  4. Add the routes from routes-snippet.php to app/Config/Routes.php, and link to account/passkeys from wherever your account settings page lives. If you registered PasskeyActivator in step 5, add its "skip" route by default - the enrollment view always renders a "skip for now" link, regardless of whether this route exists (a missing route makes the view detect this and simply hide the link, rather than throwing when the very first user registers), so only leave it out if you deliberately don't want "skip" offered at all (e.g. because MFA is mandatory for everyone via shield-mfa-dispatcher's $required/$requiredMethodsForGroups).

  5. Actually test it, in a real browser, before production. See the version-sensitivity section above.

Browser support for the client-side JavaScript

Every view uses PublicKeyCredential.parseCreationOptionsFromJSON() / .parseRequestOptionsFromJSON() and credential.toJSON() - the WebAuthn Level 3 JSON helpers (Chrome 122+, Safari 17.4+, and current Firefox). These exist specifically so a spec-compliant server library and a spec-compliant browser can agree on a JSON shape without any hand-written glue code converting between base64url strings and ArrayBuffers - which is what every older WebAuthn tutorial's JavaScript is full of, and is exactly the kind of fiddly, easy-to-get- subtly-wrong code this package tries to avoid needing.

If your users are on browsers old enough to lack these methods, replace the relevant block in each view's <script> with the classic manual conversion pattern (widely documented at webauthn.guide) instead.

src/Assets/passkey-login.js's passkey autofill relies on conditional mediation (navigator.credentials.get() with mediation: 'conditional'), supported by current Chrome, Edge, Safari and Firefox. The script checks PublicKeyCredential.isConditionalMediationAvailable() first and does nothing at all where it's unsupported - the login form simply works as a normal password form there, and the "Login with a passkey" button (if you use it) still works.

Enrollment used to require a confusing second click - fixed

Fixed in the current version - update if you're on an older copy. passkey_activator_enroll.php and passkey_settings_enroll.php used to show a status message ("Passkey created - click below to finish") after a successful registration ceremony, then require the visitor to click a second button to actually complete enrollment - a button that shared the exact same label text as the first one, making it look like nothing had happened. Combined with an explicit "Cancel" link sitting right next to it (on the settings-page version) or a "skip" link right below it (on the registration-time version), the whole flow looked broken - confirmed to cause real users to abandon enrollment entirely, or need multiple attempts to find the right button to click.

There's no good reason to require a second, manual click at all once the ceremony has already succeeded - both views now call the form's own requestSubmit() automatically the instant navigator.credentials.create() resolves, so enrollment completes with a single click, the way it should have from the start.

Overriding views

Every view is looked up through Config\PasskeyMfa::$views, the same pattern Shield itself uses for Config\Auth::$views, and every other package in this series uses for its own views:

public array $views = [
    'passkey_mfa_verify' => 'App\Views\auth\my_passkey_verify',
    // any key you don't list keeps using this package's default
];

Unlike the other packages in this series, a replacement view here needs to bring its own working WebAuthn JavaScript too, not just markup - copy the relevant <script> block from the default view as a starting point rather than writing one from scratch.

Why this needed two identity types, same as shield-totp-mfa

Shield decides whether an action is "pending" purely by whether an identity of getType()'s type exists in the database - not by anything about that identity's state. A permanent credential marker that's never deleted (the whole point of a passkey) is only safe for PasskeyMfa (login) to check for because enrollment happens somewhere else entirely (PasskeyActivator, or the settings page), using a separate, disposable identity type (PasskeyIdentityStore::ID_TYPE_PASSKEY_ACTIVATE) that Shield's own pending-check sees during registration instead. This is the exact same trap - and exact same fix - documented at length in shield-totp-mfa's README; not repeated here beyond this summary. See PasskeyIdentityStore's class doc comment for the specifics as they apply to this package.

The permanent marker's secret used to collide between any two users - fixed

Fixed in the current version - update if you're on an older copy, and this is the most severe bug found across this whole series. The permanent 'passkey' marker described above (PasskeyIdentityStore::syncPermanentMarker()) used to store a fixed literal string ('n/a') as its secret column, on the reasoning that "it's never read, only the marker's existence matters."

That reasoning missed Shield's own auth_identities UNIQUE(type, secret) constraint - not (user_id, type, secret). Any two users who both had at least one passkey credential enrolled would both produce (type='passkey', secret='n/a'), an identical pair. Unlike similar bugs found in this same series (see shield-whatsapp-mfa's PhoneNumberStore, which had two related ones), this one needed no coincidence and no timing window at all - it's permanent, so the second person to ever enroll a passkey in any real, multi-user app would hit a duplicate-key database error and be unable to complete registration, indefinitely, not just during a brief window. This wasn't an edge case; it was close to guaranteed to eventually surface in any app with more than one user actually adopting passkeys.

Fixed by randomizing the value (bin2hex(random_bytes(8))) instead - the exact same approach this class's own temporary activation marker (ensureActivationMarker(), a few lines above syncPermanentMarker() in the source) already used correctly. The marker's existence is still all that's ever checked by Shield's own pending-logic or by this package's own hasEnrolled(); its content remains genuinely unused, now just genuinely unique per row too.

Login completion: completeLogin(), not login()

Same confirmed-against-Shield-v1.3.0 caveat as every other package in this series: completePendingAction() calls completeLogin($user), not login($user) - see shield-totp-mfa's README for the full explanation if you hit login issues after upgrading Shield.

PasskeyActivator doubles as a forced-setup step, not just registration

Written purely for registration-time signup, but if you're using shield-mfa-dispatcher's Config\MfaDispatcher::$requiredMethodsForGroups, this class gets reused unmodified as a login-time forced-setup step too

  • see shield-totp-mfa's README (under "Design decisions worth knowing about") for the full explanation of why verify() checks $user->active before activating/redirecting, and why that one check was all that was needed to make this safe for both contexts.

A user with an existing passkey was still routed into enrollment - fixed

Fixed in the current version. A real report, if you're pairing this package with shield-mfa-dispatcher (register = PasskeyActivator::class, login = MfaDispatcher::class): a user who had already registered a passkey was still shown PasskeyActivator's own enrollment prompt on a later, ordinary login - despite shield-mfa-dispatcher's own MfaDispatcher::resolveRequiredMethod() correctly resolving them as already enrolled (confirmed via that package's own diagnostic logging). Direct log tracing confirmed MfaDispatcher::show() (the login slot) never ran at all for that request - only PasskeyActivator::show() (the register slot) did, meaning Shield itself decided the register slot was still the pending one for that user.

Root cause - CORRECTION, an earlier version of this note was factually wrong: it claimed PasskeyActivator and PasskeyMfa "deliberately share the same underlying identity type." They don't - confirmed directly in each class's own getType(): PasskeyActivator::getType() returns PasskeyIdentityStore::ID_TYPE_PASSKEY_ACTIVATE, while PasskeyMfa::getType() returns the separate PasskeyIdentityStore::ID_TYPE_PASSKEY. The actual mechanism is more likely this instead: ID_TYPE_PASSKEY_ACTIVATE is a temporary marker created during registration (see "The permanent marker's secret used to collide" above for the broader activation-marker pattern this mirrors) - if that marker is never cleaned up once registration completes, Shield would keep finding a matching identity for the register slot's own type indefinitely, long after the user has finished registering and is simply logging in again on every subsequent visit. This is a plausible explanation for the observed behavior, not a directly-confirmed one - Shield's own internal identity-matching logic wasn't inspected line by line to prove it.

Regardless of the exact mechanism, Shield's own docs on custom actions describe an appliesTo(User $user): bool method (ConditionalActionInterface) that tells Shield directly whether a given action should be considered pending for a user at all - "when appliesTo() returns false, Shield does not start the action and ignores stored identities for that action while the condition remains false." PasskeyActivator didn't implement this.

Fixed: PasskeyActivator now implements ConditionalActionInterface, returning false from appliesTo() once the user already has a registered passkey. Per Shield's own documented behavior, this should stop Shield from ever treating register as pending for that user again - confirmed working by a real user report, so the fix itself is solid even though the full mechanistic explanation above is a well-reasoned inference, not something traced through Shield's own source line by line.

If you're using TotpActivator or WhatsAppActivator from the sibling packages in this series with shield-mfa-dispatcher the same way, the identical fix has now been applied there too - see each package's own README for the same account, adapted to their own store's method names and identity types.

If a page loads but shows nothing at all

Same issue as every other package in this series: views must wrap their content in a section called 'main', matching what Shield's own layout actually renders - already handled correctly in this package's shipped views, but worth knowing if you write your own.

Step-up auth for sensitive pages (RequireFreshPasskey filter)

Everything above concerns login. This is different: a route filter that forces a fresh passkey challenge before reaching a specific page, even for a user who's already fully logged in - useful for gating sensitive actions (updating payment/API settings, changing an email address, etc.) behind re-confirmed identity, the way Stripe, GitHub, and AWS all do before letting you touch billing or security settings. Mirrors shield-totp-mfa's own RequireFreshTotp filter exactly - see that package's README for more detail on the design; the short version is repeated here.

Setup

  1. Register the filter alias in app/Config/Filters.php:

    public array $aliases = [
        // ... your existing aliases
        'passkey-fresh' => \PasskeyMfa\Filters\RequireFreshPasskey::class,
    ];
  2. Add the step-up challenge routes from routes-snippet.php (already included if you copied the whole snippet earlier).

  3. Apply it to whichever routes need protecting, alongside your normal login-required filter:

    $routes->group('admin/billing', ['filter' => ['session', 'passkey-fresh']], static function ($routes) {
        $routes->get('stripe-settings', 'Admin\BillingController::index');
        $routes->post('stripe-settings', 'Admin\BillingController::update');
    });

That's it - a user reaching admin/billing/stripe-settings without a recent-enough passkey challenge gets sent to a short WebAuthn challenge page first, then bounced back to where they were headed.

How freshness works

A timestamp is stashed in session the moment a challenge succeeds. Subsequent requests to any passkey-fresh-protected route within $config->stepUpFreshnessSeconds (default 15 minutes) pass straight through without asking again; after that window, the next protected page reached asks again.

What happens if the user has no passkey registered at all

By default, RequireFreshPasskey lets them through - there's nothing to challenge them with, so the filter doesn't lock them out of a page they have no way to unlock. If you'd rather force enrollment before such pages are reachable at all, set:

public bool $stepUpRequiresEnrollment = true;
public string $stepUpEnrollRouteName  = 'passkey-settings-enroll';

This is separate machinery from the login Action, deliberately

RequireFreshPasskey/PasskeyStepUpController don't touch Shield's ActionInterface/pending-login mechanism at all - they're an ordinary CodeIgniter filter and controller operating on auth()->user(), reusing PasskeyIdentityStore::beginAuthentication()/completeAuthentication() directly (the exact same WebAuthn ceremony the login action itself uses). See shield-totp-mfa's README for the fuller explanation of why step-up auth is deliberately kept out of the Action system entirely.

Optional: passkey sign-in on the login page

Two optional ways to let a visitor sign in with a passkey instead of their password, both off by default. Turn on either or both:

  • Passkey autofill ($enablePasskeyAutofill): the browser offers the visitor's passkeys in the email field's own autofill dropdown, next to any saved usernames. Choosing one signs them in; anyone who ignores it types their email and password as usual. Nothing ever pops up on its own. To see the experience, try webauthn.io: register a test passkey, reload the page, and click into the empty username field.
  • "Login with a passkey" button ($enableDiscoverableAuthentication): clicking it opens the browser's own passkey picker straight away, for visitors who prefer a button or whose browser doesn't support autofill.

Both use the same two endpoints and the same reference script (src/Assets/passkey-login.js), and both need discoverable passkeys (see below).

Requires discoverable ("resident key") passkeys

The browser has to find the visitor's passkey without being told who they are, so a passkey only appears in the autofill list or the picker if it was registered as a client-side discoverable credential (older WebAuthn terminology: a "resident key"). A non-discoverable credential still works for this package's MFA step, where the server already knows who is logging in and supplies allowCredentials, but it won't be offered here. Passkeys saved to a platform or password manager (Windows Hello, iCloud Keychain, Google Password Manager, 1Password, ...) are discoverable.

Config\PasskeyMfa::$residentKeyRequirement (default 'preferred') controls what new registrations request from the authenticator. web-auth/webauthn-lib's own default when this is omitted (which is what earlier versions of this package did) is also equivalent to 'preferred', so already-registered passkeys may or may not be discoverable, depending on what the authenticator chose at the time. This package can't detect that after the fact: if a user's existing passkey isn't offered, re-registering it (account/passkeys) is the fix.

'required' guarantees future registrations are discoverable, at a cost: registration fails outright on an authenticator that can't create one. Passkey managers always can, but older, non-passkey-aware security keys may not. 'preferred' asks for a discoverable credential without insisting on one.

How it works

Two AJAX (JSON) endpoints (PasskeyDiscoverableAuthController), separate from the login Action (PasskeyMfa) and step-up machinery - the visitor calling them isn't logged in, or even mid-login:

  • POST auth/a/passkey-discoverable/options - takes no email or username. Always returns a fresh challenge while either option is on; the browser decides whether the visitor has a usable passkey. Because it reveals nothing about accounts, it can't be used to probe which emails are registered.
  • POST auth/a/passkey-discoverable/verify - given the browser's WebAuthn response, identifies and verifies the user in one step (PasskeyIdentityStore::completeDiscoverableAuthentication()), then logs them in.

Both return 404 while both options are off.

For autofill, passkey-login.js requests options as the page loads and calls navigator.credentials.get() with mediation: 'conditional'. That request waits silently until the visitor picks a passkey from the dropdown. Submitting the password form cancels it, and clicking the button cancels it first (a browser allows only one passkey request at a time), then restarts it if the visitor stays on the page.

CodeIgniter replaces the CSRF token after every checked POST (Config\Security::$regenerate, true by default), so each response from these endpoints carries the new token and the script writes it into the page's hidden CSRF field. If the visitor submits the password form before the page-load options request has returned (whose token it has already used up), the submission is held until the fresh token is in the form - at most 10 seconds - then sent.

The security design behind identifying the user, worth being explicit about

The assertion response's own userHandle field is what many WebAuthn tutorials read directly to answer "who logged in" - this package deliberately does not. completeDiscoverableAuthentication() looks up the credential by its ID first (PasskeyCredentialModel::findByCredentialId(), a trusted server-side lookup populated only at registration time), derives a candidate user from that row's user_id, and only then runs the cryptographic signature check (assertionValidator()->check()) against that candidate. If the check fails, the candidate is never trusted, whatever the response's userHandle claimed.

Setup

  1. In app/Config/PasskeyMfa.php, set $enablePasskeyAutofill = true, $enableDiscoverableAuthentication = true, or both. Decide your $residentKeyRequirement (see above).

  2. Add the two passkey-discoverable routes from routes-snippet.php.

  3. Check app/Config/Filters.php's $globals for a login-required filter (commonly session or isLoggedIn) applied to every request. If there is one, its 'except' list must cover these two routes, or the filter redirects them to your login page and the script receives HTML where it expected JSON - which looks like nothing happening at all. The routes live under auth/a/..., which many Shield apps already exclude, for example:

    'session' => ['except' => ['login*', 'register', 'auth/a/*', 'logout']],
  4. On your login page:

    • For autofill: nothing to add. The script adds the webauthn token to the email field's autocomplete attribute if it's missing (Shield's default autocomplete="email" becomes email webauthn). You can put it in your markup yourself instead.
    • For the button: add e.g. <button type="button" id="passkey-discoverable-login">Login with a passkey</button>, optionally with a status element (<div id="passkey-discoverable-status"></div>).
  5. Copy src/Assets/passkey-login.js into your login page (it isn't loaded automatically, and updating the package doesn't update your copy). Adjust the settings at the top of the file if your markup or routes differ: the CSRF field name (Config\Security::$tokenName, default csrf_test_name), the email field and button selectors, and the two route paths.

  6. Test it in a real browser with a real, discoverable passkey registered. If the button's picker opens but offers nothing, or the autofill list shows no passkey, that's most likely the discoverability prerequisite above.

Does this bypass your app's own MFA?

Config\PasskeyMfa::$earlyAuthenticationIsSufficient (default true) decides this, for both options. A passkey is already a strong, phishing-resistant credential that's inherently multi-factor (possession of the device plus its own biometric/PIN unlock), verified directly by this app rather than delegated to a third party - unlike shield-oauth-login's equivalent toggle ($triggerMfaAfterSso, which defaults to still requiring MFA), treating it as sufficient on its own is the more defensible default here. Set it to false to layer your app's own MFA (e.g. shield-mfa-dispatcher) on top: a user who signs in this way is then sent to the normal MFA challenge page.

The reflection-based login completion, and why it's needed here too

When $earlyAuthenticationIsSufficient is false, CompletesEarlyLogin::completeEarlyLogin() uses the same reflection-based mechanism shield-oauth-login's OAuthLoginController::completeLogin() needed, for the same reason: Shield's own Session::attempt() is the only path that triggers its private setAuthAction() pending-check, and attempt() requires a password, which a visitor signing in with a passkey hasn't given. There is no public Shield API for "log this already-verified user in, but still check whether MFA should apply first." See that method's own doc comment for the fuller account.

Why autofill replaced the "prompt when the email field loses focus" feature

Earlier versions had a different login-page option, $enableEarlyAuthentication: when the visitor left the email field, the page looked up whether that email had a passkey and, if so, opened the passkey prompt straight away. It was removed because a page that starts prompts on its own races the visitor's own actions:

  • A leftover prompt on the next page. If the visitor clicked the normal login button just as the prompt was starting, it could still appear after the browser had moved on - on Windows, where the prompt is Windows Hello's own dialog, even after the page had cancelled the request. Page JavaScript can't control that.
  • Failed logins. Pressing the mouse on the login button takes focus off the email field before the click submits, so the lookup and the login were sent at the same moment. The lookup used up the page's CSRF token (CodeIgniter replaces it after every checked POST), so the login could be rejected with "The action you requested is not allowed."

Autofill never prompts on its own, so neither can happen. It also needs no email lookup endpoint.

Upgrading from a version with $enableEarlyAuthentication:

  1. Replace it with $enablePasskeyAutofill = true in app/Config/PasskeyMfa.php.
  2. Remove the two passkey-early routes (auth/a/passkey-early/options and /verify) from app/Config/Routes.php, and add the two passkey-discoverable routes if you don't have them yet.
  3. Replace your copy of passkey-login.js with the new one.

A stuck browser ceremony can block every passkey operation on the device

A real report confirmed a genuinely severe browser-level behavior, worth understanding even though this package can't prevent it directly. Choosing a passkey identity in the browser's own picker that isn't actually registered with this site - something the "Login with a passkey" button's discoverable request can show, since it displays every identity the platform has for its own ecosystem, not just ones this app knows about - can leave navigator.credentials.get() hanging for the platform's own full internal timeout (observed at roughly 2 minutes) before it finally rejects with a generic NotAllowedError: The operation either timed out or was not allowed.

Worse than just a slow failure: during that entire window, the same report showed the browser itself refusing to start any other WebAuthn ceremony at all, anywhere on the device - including a completely unrelated one, like the normal password login's own separate 2FA challenge. From the outside this looked like choosing the wrong identity in the button's picker had "broken" passkey login entirely, until the roughly-2-minute window passed and everything started working again. This is confirmed, deliberate browser behavior (the same privacy protection covered above - a browser cannot reveal why a WebAuthn operation is refused, so a stuck internal resolution and a straightforward rejection look identical from a website's own JavaScript), not a bug in this package, and not something a website's own code can bypass or speed up.

Mitigated, not fixed: the "Login with a passkey" button in passkey-login.js imposes its own 20-second client-side timeout (via AbortController), so at least the page gives up and re-enables its own UI with a clear message well before the browser's own much longer timeout would. Passkey autofill has no timeout by design - it waits, invisibly, until the visitor picks a passkey - and the login form stays usable with a password throughout. This does not prevent the underlying browser-level lock on other ceremonies during that window - only the browser itself controls that

  • but it does mean your own login page stops looking silently stuck after 20 seconds instead of up to two minutes, and gives the visitor an honest "that took too long, please try again" message rather than nothing at all.

Why the button and autofill can offer the "wrong" identity

Worth understanding as a genuine, structural property of usernameless passkey sign-in, not a bug.

This was never a security risk, confirmed via multiple independent sources on how WebAuthn's own rpId restriction works: it's enforced as a hard, cryptographic check at the authenticator itself, not just a browser UI convention - a credential genuinely registered for a different site cannot be signed for yours, even if a user is deliberately tricked into trying (this is the same property that makes passkeys phishing-resistant in the first place). So picking an unexpected identity from the picker or autofill list was never able to log anyone into the wrong account or leak anything - at worst it fails, as covered above.

What's actually happening, most likely: if a visitor is signed into more than one account/profile on the same device (common with Google or Microsoft accounts specifically), the browser's own passkey picker can offer a "use a different account" option that switches profiles entirely - this is a browser/OS-level UI decision, not something a website's PublicKeyCredentialRequestOptions has any control over. Only after a visitor picks one of these does the browser attempt to find a matching credential in that other profile, which is what leads to the stuck ceremony covered above when no match exists there.

Why this can't be avoided without knowing the account first: a request that already knows who is logging in can list the exact acceptable credentials (allowCredentials), leaving the browser nothing else to offer - which is how this package's MFA step works, and how the removed email-blur feature worked. Both usernameless options deliberately don't know the account up front; that's what lets a visitor sign in without typing anything. The button's 20-second timeout above is the safety net if someone does pick the wrong identity.

Diagnostic logging is gated to development only

PasskeyActivator::show(), PasskeyMfa::verify(), and PasskeyIdentityStore::completeAuthentication()/ completeDiscoverableAuthentication() log detailed information (user_id, credential_id, exception details, and - for PasskeyActivator::show() - the current request URI) at various points while resolving and verifying passkey ceremonies. This was added while diagnosing several real, confirmed bugs across this package (see the sections above for each one's own account) and is left in permanently, since it's genuinely useful the next time something in this flow needs diagnosing.

A real concern, worth addressing directly: logging user_id and credential_id values on every passkey attempt isn't something that should silently accumulate in a production application's log just because a past investigation needed the visibility. All of it is routed through PasskeyMfa\Libraries\DiagnosticLog::write(), a thin wrapper around log_message() that only actually writes when ENVIRONMENT === 'development' - in any other environment (staging, production, testing), these calls are silent no-ops.

The same gating also applies to something arguably more sensitive than the log calls themselves: PasskeyMfa::verify() used to append a [diagnostic: ...] suffix directly to the flash message shown to the end user on a failed verification, not just to the log - added during an earlier investigation into a bug that gave zero visibility any other way. That's now gated through the same ENVIRONMENT === 'development' check (verificationFailedMessage()), so production users only ever see the plain, generic failure message (lang('PasskeyMfa.verificationFailed')) - the specific internal reason is never shown to them outside a development environment.

If you need this visibility again on a production-like environment, the practical option is reproducing the issue with CI_ENVIRONMENT=development set for that specific session, not turning on logging that writes to your real production log (or shows internal details to real users) continuously.

Apps that keep Shield's tables on their own connection (Config\Auth::$DBGroup) - fixed

Shield lets you put its tables on a database group other than the default one with Config\Auth::$DBGroup, and its own models and migration follow that setting. This package's auth_passkey_credentials table didn't: PasskeyCredentialModel and the migration that creates the table used the default connection.

That only goes wrong when the default connection isn't where the users are. A multi-tenant app is the usual case: users live in a central database, and each request switches the default connection to the current tenant's database. There, every passkey sign-in and settings page failed with "table doesn't exist", because it looked in the tenant's database.

Both now use Config\Auth::$DBGroup when it's set, and the default connection when it's null (Shield's default). Nothing changes for apps that don't set it.

Tests

If you're using shield-mfa-dispatcher (or anything else that makes Config\Auth::$actions point at something other than PasskeyMfa/PasskeyActivator directly): the confirmed fixes shield-totp-mfa needed for this exact same architecture (session leakage between test methods, resetServices()'s own route-wiping side effect, and reflection-based pending-state simulation for the registration-time activator once 'login' points elsewhere) are applied here too - see shield-totp-mfa's README and its TotpMfaTest/TotpActivatorTest class doc comments for the full, diagnostic-backed account; not repeated here in full since the mechanism is identical. PasskeyMfaTest and PasskeyActivatorTest have the complete fix; RequireFreshPasskeyTest, PasskeyStepUpControllerTest, and PasskeySettingsControllerTest have the defensive Services::routes()->loadRoutes() piece only, since they use actingAs() rather than attempt() and were never affected by the session/pending-state issues specifically.

tests/PasskeyMfa/ covers everything genuinely testable without a real browser and authenticator: marker sync, ownership checks, JSON shape, and graceful failure handling. It deliberately does not cover the actual cryptographic verification succeeding - see below.

tests/PasskeyMfa/
  Libraries/Base64UrlTest.php                  <- pure codec round-trip, no DB/HTTP
  Libraries/SyncedPasskeyCounterCheckerTest.php <- the fixed counter-check logic, in isolation -
                                                     no cryptography involved, fully covered
  Libraries/PasskeyIdentityStoreTest.php        <- marker sync, ownership, JSON shape, graceful failures,
                                                     the regression test for the fixed duplicate-key bug,
                                                     and beginDiscoverableAuthentication()'s own JSON shape
  Authentication/Actions/PasskeyMfaTest.php     <- login action: getType/createIdentity, show() smoke test
  Authentication/Actions/PasskeyActivatorTest.php <- register action: same, plus skip,
                                                      plus appliesTo() - the fix for the
                                                      confirmed register-slot-always-wins bug
  Controllers/PasskeySettingsControllerTest.php <- list/rename/remove only (not enroll/confirm)
  Filters/RequireFreshPasskeyTest.php           <- step-up freshness/enrollment logic
  Controllers/PasskeyStepUpControllerTest.php   <- step-up challenge page (show() smoke test,
                                                    graceful verify() failure - not the crypto success path)
  Controllers/PasskeyDiscoverableAuthControllerTest.php <- login-page passkey sign-in endpoints:
                                                     enabled by either option (autofill or button),
                                                     404 when both are off, no-allowCredentials shape,
                                                     graceful verify() failure with no matching credential

Fixes from running the suite on CodeIgniter 4.7 / PHP 8.5

  • Data too long for column 'username'. Shield's users.username is VARCHAR(30), and uniqid() adds 13 characters. The passkeyactivatortest and passkeysettingstest prefixes went over the limit. They're now pkactest and pksettest.
  • Early-auth "unavailable" tests expecting a bare {"available": false}. These tests went with the email-blur endpoints, which have since been removed along with PasskeyEarlyAuthControllerTest (see "Why autofill replaced the 'prompt when the email field loses focus' feature").
  • A 404 leaking between controller tests. The discoverable-login controller tests shared one response object, so a status an earlier test set (404) was still there for the next one, and options() never sets 200 explicitly. Each test controller now gets a fresh response, as a real request does.
  • POST data invisible on CodeIgniter 4.7+. From 4.7, a request reads POST data from a shared superglobals snapshot, taken the first time anything touches the request. The tests' request helpers now also call $request->setGlobal('post', $post), which works on 4.6 and 4.7.

Setup

  1. Copy tests/PasskeyMfa into your app's own tests/ folder, the same way src/ gets installed - see "Installation" above.

  2. Make sure your test database has Shield's own migrations and this package's migration applied - protected $namespace = null; in each test class triggers this automatically, equivalent to php spark migrate --all, as long as the connection itself works.

  3. Run it the same way as the rest of your suite:

    vendor/bin/phpunit tests/PasskeyMfa
    

Why the actual cryptographic success path isn't tested here

Producing a genuinely valid, correctly-signed WebAuthn registration or authentication response requires an actual authenticator (a real device, or a software/virtual one) - there's no equivalent here to shield-totp-mfa's RFC 6238 test vectors, since WebAuthn's signatures are tied to a real private key that only ever exists inside an authenticator, by design. Faking one to make completeRegistration()/ completeAuthentication() return true isn't something this test suite attempts.

What IS tested instead, and is genuinely valuable:

  • Marker sync (testRemovingTheLastCredentialRemovesThePermanentMarker and friends) - the exact mechanism that had a real, hard-to-find bug in shield-totp-mfa's own history, so it's worth covering thoroughly here too.
  • Ownership checks - one user can never rename or remove another user's credential, at both the store level and through the controller.
  • beginRegistration()/beginAuthentication() actually running at all. These exercise real web-auth/webauthn-lib object construction and serialization - not the verification step, but enough that if WebauthnFactory's setup is wrong for your installed library version (see that file's own doc comment for how much this API has moved across versions), these tests are where that surfaces, not silently later during an actual user's registration attempt.
  • Graceful failure - completeRegistration()/completeAuthentication() return false rather than throwing for a missing challenge or garbage input, which PasskeyMfaTest/PasskeyActivatorTest confirm end-to-end through verify() as well.

Before relying on this package in production, the one thing this test suite cannot substitute for is registering and logging in with a real browser and a real authenticator at least once. This is stated plainly rather than implied, since "the tests pass" would otherwise be easy to over-read as "the cryptography works."