cloudrepublic / shield-passkey-mfa
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
pkg:composer/cloudrepublic/shield-passkey-mfa
Requires
- php: ^8.2
- codeigniter4/framework: ^4.6
- codeigniter4/shield: ^1.4
- web-auth/webauthn-lib: ^5.3
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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-lib5.3 or later (installed automatically by Composer). 5.3 is the first version withWebauthn\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
-
$rpIdmust 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.comalso coversapp.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. -
WebAuthn requires either HTTPS or
localhost, with no exceptions. Testing over plain HTTP on anything other thanlocalhostitself (including127.0.0.1, and including a.localor.testdomain 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 reallocalhostURL, 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
- 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. - 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)
-
composer require cloudrepublic/shield-passkey-mfa web-auth/webauthn-lib. -
Run the setup command - publishes
Config/PasskeyMfa.phpandLanguage/en/PasskeyMfa.phpinto your app:php spark passkey-mfa:setupThe migration is not copied anywhere - see step 4.
Option B - manual drop-in
- Copy
src/into your app (e.g.app/ThirdParty/PasskeyMfa/src), register thePasskeyMfanamespace inapp/Config/Autoload.php, and separatelycomposer require web-auth/webauthn-lib(this one genuinely needs Composer either way - it isn't something reasonable to vendor by hand). - Copy
Config/PasskeyMfa.phptoapp/Config/PasskeyMfa.php, andLanguage/en/PasskeyMfa.phptoapp/Language/en/PasskeyMfa.php.
Either way, finish with these
-
Set
$rpNameand$rpIdinapp/Config/PasskeyMfa.php(or via.env) - see the gotchas above for$rpIdspecifically. -
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 PasskeyMfato target just this package). -
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. -
Add the routes from
routes-snippet.phptoapp/Config/Routes.php, and link toaccount/passkeysfrom wherever your account settings page lives. If you registeredPasskeyActivatorin 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 viashield-mfa-dispatcher's$required/$requiredMethodsForGroups). -
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 whyverify()checks$user->activebefore 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
-
Register the filter alias in
app/Config/Filters.php:public array $aliases = [ // ... your existing aliases 'passkey-fresh' => \PasskeyMfa\Filters\RequireFreshPasskey::class, ];
-
Add the step-up challenge routes from
routes-snippet.php(already included if you copied the whole snippet earlier). -
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
-
In
app/Config/PasskeyMfa.php, set$enablePasskeyAutofill = true,$enableDiscoverableAuthentication = true, or both. Decide your$residentKeyRequirement(see above). -
Add the two
passkey-discoverableroutes fromroutes-snippet.php. -
Check
app/Config/Filters.php's$globalsfor a login-required filter (commonlysessionorisLoggedIn) 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 underauth/a/..., which many Shield apps already exclude, for example:'session' => ['except' => ['login*', 'register', 'auth/a/*', 'logout']],
-
On your login page:
- For autofill: nothing to add. The script adds the
webauthntoken to the email field'sautocompleteattribute if it's missing (Shield's defaultautocomplete="email"becomesemail 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>).
- For autofill: nothing to add. The script adds the
-
Copy
src/Assets/passkey-login.jsinto 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, defaultcsrf_test_name), the email field and button selectors, and the two route paths. -
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:
- Replace it with
$enablePasskeyAutofill = trueinapp/Config/PasskeyMfa.php. - Remove the two
passkey-earlyroutes (auth/a/passkey-early/optionsand/verify) fromapp/Config/Routes.php, and add the twopasskey-discoverableroutes if you don't have them yet. - Replace your copy of
passkey-login.jswith 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'susers.usernameisVARCHAR(30), anduniqid()adds 13 characters. Thepasskeyactivatortestandpasskeysettingstestprefixes went over the limit. They're nowpkactestandpksettest.- Early-auth "unavailable" tests expecting a bare
{"available": false}. These tests went with the email-blur endpoints, which have since been removed along withPasskeyEarlyAuthControllerTest(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
superglobalssnapshot, 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
-
Copy
tests/PasskeyMfainto your app's owntests/folder, the same waysrc/gets installed - see "Installation" above. -
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 tophp spark migrate --all, as long as the connection itself works. -
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 (
testRemovingTheLastCredentialRemovesThePermanentMarkerand friends) - the exact mechanism that had a real, hard-to-find bug inshield-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 realweb-auth/webauthn-libobject construction and serialization - not the verification step, but enough that ifWebauthnFactory'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()returnfalserather than throwing for a missing challenge or garbage input, whichPasskeyMfaTest/PasskeyActivatorTestconfirm end-to-end throughverify()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."