dereuromark / cakephp-passkeys
Public-quality passkey / WebAuthn authentication plugin for CakePHP 5
Package info
github.com/dereuromark/cakephp-passkeys
Type:cakephp-plugin
pkg:composer/dereuromark/cakephp-passkeys
Fund package maintenance!
Requires
- php: >=8.2
- cakephp/cakephp: ^5.3
- cakephp/migrations: ^5.0
- web-auth/webauthn-lib: ^5.3.5
Requires (Dev)
- cakedc/cakephp-phpstan: ^4.0
- cakephp/authentication: ^3.0 || ^4.0
- dereuromark/cakephp-fixture-factories: ^2.0
- dereuromark/cakephp-i18n-lint: ^0.1.2
- php-collective/code-sniffer: dev-master
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0 || ^13.0
Suggests
- cakephp/authentication: The signed-in user is then persisted as identity after a passkey sign-in.
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 0.1.2
- 0.1.1
- 0.1.0
- dev-release/0.1.3
- dev-feature/second-factor
- dev-feature/prune-command
- dev-release/0.1.2
- dev-feature/backup-state
- dev-feature/signal-api
- dev-feature/register-alert-mail
- dev-feature/aaguid-allowlist
- dev-feature/cli-commands
- dev-feature/well-known-passkey-endpoints
- dev-ci/release-version-guard
- dev-feature/throttle-remaining
- dev-release/0.1.1
- dev-ci/trim-matrix
- dev-fix/release-hardening
- dev-docs/release-0.1.1
- dev-ci/release-0.1.1
- dev-feature/rate-limiting
- dev-ci/prefer-lowest
- dev-feature/before-delete
- dev-feature/persist-identity
This package is auto-updated.
Last update: 2026-11-05 22:27:17 UTC
README
Passkey (WebAuthn) sign-in for CakePHP 5.
- Register, sign in and re-confirm with a passkey. Eight JSON endpoints, backed by
web-auth/webauthn-lib. - A JavaScript bundle that binds
data-passkey-*attributes. No build step needed. The same code is on npm with React, Vue, Alpine and Stimulus adapters. - Cells for a passkey list, a sign-in button and an "add a passkey" banner.
- No assumptions about your authentication stack: you tell the plugin who the current user is, and it tells you who signed in.
Requirements
- PHP 8.2+, CakePHP 5.3+
- HTTPS in production. Browsers only offer WebAuthn in a secure context;
http://localhostcounts as one for development. - A cache engine that keeps data between two requests (not
NullorArray) for the challenges.
Installation
composer require dereuromark/cakephp-passkeys bin/cake plugin load CakePasskeys bin/cake migrations migrate -p CakePasskeys
If the primary key of your users table is not an integer, set CakePasskeys.users.idType before you run the migration. See Configuration.
Configuration
At minimum, set the relying party id. It is the domain your users see, without scheme and port.
// config/app_local.php 'CakePasskeys' => [ 'rpId' => 'example.com', 'rpName' => 'Example App', 'allowedOrigins' => ['https://example.com'], ],
config/app.example.php lists every key with its default. The ones you are most likely to touch:
| Key | Default | Purpose |
|---|---|---|
enabled |
true |
Master switch. With false the endpoints answer 404 and the cells and helper render nothing. |
rpId |
'localhost' |
Relying party id. A passkey is bound to it for good, so choose it before users enroll. |
rpName |
'My App' |
Name the authenticator shows. |
allowedOrigins |
null |
Origins a response may come from. Without a list, any HTTPS origin on the rpId or a subdomain of it is accepted, on any port. Set it in production. |
allowedAaguids |
null |
AAGUIDs (authenticator models) a new passkey may come from. null or [] accepts any authenticator. A policy filter, not proof of the make. See Security. |
users.table |
'Users' |
Table your users live in. |
users.columns |
id, email, name |
Column names for the id, the email and the display name. |
users.activeColumn |
null |
Boolean column. Users where it is false cannot register or sign in. |
users.userHandle |
null |
Closure fn (EntityInterface $user): string for the WebAuthn user handle, at most 64 bytes. Set it when your app already has passkeys registered under its own handle. See Moving existing passkeys. |
users.idType |
'integer' |
Type of passkeys.user_id: integer, biginteger, uuid or string. Read by the migration. |
identityResolver |
null |
Closure that returns the id of the signed-in user. See Who is signed in. |
session.persistIdentity |
true |
Hand the signed-in user to cakephp/authentication when it is loaded. See Pick up the signed-in user. |
session.userIdKey |
'Auth.id' |
Session key the user id is written to after a passkey sign-in. |
ceremony.userVerification |
'required' |
required, preferred or discouraged. Only required sets the MFA flag. |
ceremony.residentKey |
'preferred' |
Whether the authenticator stores the account, which sign-in without an email needs. |
ceremony.attestation |
'none' |
Attestation conveyance passed to the browser. Leave it at none: the plugin only accepts the none attestation format, so other values make registrations fail on authenticators that send a statement. See docs/SecurityModel.md. |
ceremony.timeout |
60000 |
Milliseconds the browser waits for the authenticator. |
mfa.sessionFlag |
'CakePasskeys.mfa_satisfied' |
Session key set to true after a user-verified sign-in. |
afterLoginRedirect |
'/' |
URL the JavaScript navigates to after a sign-in. |
secondFactor.pendingUserKey |
null |
Session key your password step writes the pending user id to. Turns on the second-factor endpoints. See Passkey as a second factor. |
secondFactor.pendingUserResolver |
null |
Closure fn (ServerRequest $request): string|int|null for the pending user id. Wins over pendingUserKey. |
secondFactor.redirect |
null |
URL the JavaScript navigates to after the second factor. null uses afterLoginRedirect. |
maxPerUser |
5 |
Passkeys per user. 0 for no limit. |
login.emailHint |
false |
Accept an email on login/start. See Security. |
challengeTtl |
300 |
Seconds a challenge stays valid between start and finish. |
reauthWindow |
900 |
Seconds a re-confirmation counts as fresh. |
cache |
'default' |
Cache config the plugin copies for its challenges (passkeys_challenges) and rate limiting (passkeys_rate_limit). Must keep data between requests; Redis, Memcached or APCu make challenge use and rate counting atomic. |
rateLimiter |
null |
Class or instance implementing RateLimiterInterface. null uses the built-in cache-backed limiter, false turns limiting off. |
docsUrl |
null |
Link shown in the empty state of the Manager cell. |
nudge.enabled |
true |
Show the RegisterNudge cell to signed-in users without a passkey. |
nudge.deviceBoundOnly |
true |
Also show it, with copy suggesting a second passkey, when every passkey of the user is known to be bound to one device and the user is below maxPerUser. |
nudge.redisplayAfterDays |
14 |
Days until a dismissed nudge shows again. |
signals |
true |
Keep the browser's passkey list in sync through the WebAuthn Signal API. See Keeping passkey lists in sync. |
urlPrefix |
'/passkeys' |
Where the endpoints are mounted. |
wellKnown.enroll, wellKnown.manage |
null |
URLs (string or URL array) served at /.well-known/passkey-endpoints. See Password manager links. |
notify.onRegister |
false |
Mail the user when a passkey is added to their account. See Alert mail. |
notify.emailProfile |
'default' |
Mailer profile the alert is sent with. |
notify.manageUrl |
null |
Link to your passkey management page, shown in the alert. A URL string or a routing array. |
Usage
1. Load the script and the endpoint list
In the <head> of the layout, or on the pages that show a passkey control:
<?= $this->Passkeys->endpointsMeta() ?> <?= $this->Passkeys->script() ?>
Load the helper in your AppView:
$this->addHelper('CakePasskeys.Passkeys');
script() serves the bundle as a plugin asset. If your web server serves plugin assets itself, run bin/cake plugin assets symlink once.
2. Let signed-in users manage their passkeys
<?= $this->cell('CakePasskeys.Manager') ?>
Shows the user's passkeys with rename and delete, and an "Add a passkey" button until the limit is reached.
Each passkey is labeled "Synced" when the authenticator reports it as backed up (iCloud Keychain, Google Password Manager and the like), or "This device only" when it cannot be synced at all. Such a passkey is lost with the device or security key it lives on. Passkeys registered before the plugin stored these flags show no label until their next sign-in.
To nudge users who have no passkey yet, or whose passkeys are all bound to one device, render the RegisterNudge cell, for example in the layout:
<?= $this->cell('CakePasskeys.RegisterNudge') ?>
A dismissed nudge stays hidden for nudge.redisplayAfterDays, remembered per browser. The two variants are dismissed separately.
3. Offer the sign-in
On the login page:
<?= $this->Passkeys->loginButton() ?> <?= $this->Form->control('email', $this->Passkeys->autofillAttribute()) ?>
The button stays hidden in browsers without WebAuthn. autofillAttribute() lets the browser offer passkeys in the autofill dropdown of the input, without a click on the button.
4. Pick up the signed-in user
After a successful sign-in the plugin:
- renews the session id
- signs the user in (see below)
- dispatches
CakePasskeys.afterLogin - answers with
{"redirectTo": ..., "mfaSatisfied": ..., "userId": ...}plus any keys anafterLoginlistener added, and the JavaScript navigates toredirectTo
How the user gets signed in:
- With
cakephp/authenticationloaded, the plugin loads the user row fromusers.tableand passes it to the authentication service'spersistIdentity(). With its Session authenticator, the next request sees the full user entity as its identity, the same as after a password login. Nothing to configure. - The plugin also writes the user id to the session key in
session.userIdKey, for your own login code to read. It skips that write when the persisted identity already sits under the same root key (Auth.idnext to the Session authenticator'sAuth). Setsession.userIdKeyto an empty string if nothing reads it. session.persistIdentityset tofalseturns off the hand-over; then onlysession.userIdKeyis written.
Who is signed in
Registering, renaming, deleting and re-confirming need the current user. The plugin looks in this order:
-
CakePasskeys.identityResolver, if set:'identityResolver' => fn (\Cake\Http\ServerRequest $request) => $request->getAttribute('authUser')?->id,
-
The
identityrequest attribute, as set bycakephp/authentication. Objects withgetIdentifier(), objects with anidproperty and arrays with anidkey all work. -
The session key in
session.userIdKey.
The user row is then loaded from users.table. If your user entity implements CakePasskeys\Contract\PasskeyUserInterface, the plugin uses it as is. Otherwise it wraps the entity and reads the configured columns.
Re-confirming before a sensitive action
Ask for a fresh passkey confirmation before, say, changing the email address.
In the template, mark the form:
<?= $this->Form->create($user, $this->Passkeys->reauthAttributes('change-email')) ?>
The JavaScript runs the ceremony when the form is submitted and submits it afterwards. That is a convenience for the user. The check that counts is on the server:
use CakePasskeys\Service\Reauth; if (!Reauth::isFresh($this->request, 'change-email')) { throw new ForbiddenException(); }
A confirmation stays fresh for reauthWindow seconds (default 900).
Passkey as a second factor
An app that signs in with a password and then asks for a second factor (a TOTP code, say) can accept a passkey for that second step. Either factor completes the sign-in.
Your password step records who passed it. Write the user id to a session key once the password is verified, and point the plugin at that key:
// config/app_local.php 'CakePasskeys' => [ 'secondFactor' => [ 'pendingUserKey' => 'TwoFactor.pending_user_id', ], ],
// your login action, after the password check succeeded $this->request->getSession()->write('TwoFactor.pending_user_id', $user->id);
On the page that asks for the TOTP code, offer the passkey next to it:
<?= $this->Passkeys->script() ?> <?= $this->Passkeys->endpointsMeta() ?> <button type="button" data-passkey-second-factor hidden>Use a passkey instead</button>
The button stays hidden in browsers without WebAuthn. It calls second-factor/start, which offers only the pending user's passkeys, and second-factor/finish, which accepts only an assertion by one of them. On success the plugin completes the sign-in as login/finish does: new session id, identity handed to cakephp/authentication (or written to session.userIdKey), mfa.sessionFlag set when user verification is required. It removes pendingUserKey from the session, dispatches CakePasskeys.afterSecondFactor and navigates to secondFactor.redirect.
If the pending state does not live in one session key, set a resolver instead. It wins over pendingUserKey, and the plugin then leaves your state alone, so clear it yourself, for example in an afterSecondFactor listener:
'secondFactor' => [ 'pendingUserResolver' => fn (\Cake\Http\ServerRequest $request) => $request->getSession()->read('Login.pending.id'), ],
Without a key or a resolver both endpoints answer 404. Without a pending user they answer 401, and start answers 400 when the pending user has no passkey. See docs/SecurityModel.md for what your password step must guarantee.
Events
Dispatched on the global event manager. The payload is a CakePasskeys\Event\PasskeyEvent under the key event.
| Event | When | Extra data |
|---|---|---|
CakePasskeys.afterRegister |
a passkey was stored | |
CakePasskeys.afterLogin |
a user signed in | |
CakePasskeys.afterSecondFactor |
a passkey completed the second factor | |
CakePasskeys.afterRename |
a passkey was renamed | old, new |
CakePasskeys.afterDelete |
a passkey was deleted |
CakePasskeys.beforeRegister runs before both register steps, with the user under user instead of a payload. Stop it to refuse the registration with a 403; a string result becomes the message:
EventManager::instance()->on('CakePasskeys.beforeRegister', function (EventInterface $event): void { $session = $event->getSubject()->getRequest()->getSession(); // wherever your login code records the sign-in time if ((int)$session->read('Login.time') < time() - 900) { $event->stopPropagation(); $event->setResult(__('Please sign in again to add a passkey.')); } });
Registering a passkey gives lasting access to the account, so asking for a recent sign-in here is a good default. CakePasskeys.beforeDelete works the same way for deleting one (with the passkey under passkey), so a stolen session cannot remove the owner's passkeys either.
Attach listeners where they exist for every request, such as Application::bootstrap():
use Cake\Event\EventInterface; use Cake\Event\EventManager; EventManager::instance()->on('CakePasskeys.afterLogin', function (EventInterface $event): void { /** @var \CakePasskeys\Event\PasskeyEvent $passkeyEvent */ $passkeyEvent = $event->getData('event'); // $passkeyEvent->getPasskey()->user_id });
afterDelete also fires from the passkeys revoke and passkeys prune commands, with the command as subject; check the subject before calling getRequest() on it.
A listener attached in your AppController does not run: the plugin's controller does not extend it. See docs/Events.md.
Alert mail for new passkeys
A passkey added from a stolen session gives the attacker lasting access. With notify.onRegister enabled, the plugin mails the account owner after every registration, so it does not go unnoticed:
'CakePasskeys' => [ 'notify' => [ 'onRegister' => true, 'emailProfile' => 'default', 'manageUrl' => ['controller' => 'Account', 'action' => 'security'], ], ],
The mail goes to the user's email (users.columns.email, or getPasskeyDisplayEmail()) and names the passkey, the device (when the authenticator model is known) and the time. It tells the user to remove the passkey and secure the account if it was not them. It is sent after the passkey is stored and after afterRegister; if sending fails, a warning is logged and the registration still succeeds. Users without an email get no mail.
The mail is CakePasskeys\Mailer\PasskeyMailer::registered(), with the templates CakePasskeys.registered and the layout CakePasskeys.default. To change them, copy them into your app:
templates/plugin/CakePasskeys/email/html/registered.phptemplates/plugin/CakePasskeys/email/text/registered.phptemplates/plugin/CakePasskeys/layout/email/html/default.php(andtext/default.php)
Subject and text are in the passkeys translation domain.
JavaScript
script() loads an 8 kB bundle that binds these attributes when the page has loaded:
| Attribute | On | Effect |
|---|---|---|
data-passkey-register |
button | registers a passkey, then reloads |
data-passkey-authenticate |
button | signs in, then navigates to redirectTo |
data-passkey-second-factor |
button | runs the second factor, then navigates to redirectTo |
data-passkey-conditional |
input | offers passkeys in the autofill dropdown |
data-passkey-reauth-required="action" |
form | asks for a passkey before submitting |
data-passkey-rename, data-passkey-delete |
button inside [data-passkey-row] |
renames or deletes that passkey |
Errors are dispatched as a passkeys:error event on document.
The same code is on npm, for bundlers and frameworks:
npm install @dereuromark/cakephp-passkeys
import { register, authenticate, conditional, reauth, secondFactor, rename, remove } from '@dereuromark/cakephp-passkeys';
Adapters for React, Vue, Alpine and Stimulus are documented in docs/JsApi.md.
Keeping passkey lists in sync
Browsers with the WebAuthn Signal API (Chrome 132 and later) are told when the server's view changes, so password managers do not keep offering passkeys that no longer work:
- A sign-in with a passkey the server does not store calls
signalUnknownCredential(), and the browser can remove it. - After a delete, and whenever the Manager cell loads,
signalAllAcceptedCredentials()lists the signed-in user's remaining passkeys. - The Manager cell also sends
signalCurrentUserDetails()with the current email and display name, so a renamed account shows correctly.
Other browsers are unaffected, and a refused signal never breaks the page. Set CakePasskeys.signals to false to turn it off.
Password manager links
Password managers such as Chrome, 1Password and Bitwarden request GET /.well-known/passkey-endpoints (W3C draft) to find where a user can add a passkey and where to manage them. Set the pages to answer it:
'wellKnown' => [ 'enroll' => ['controller' => 'Account', 'action' => 'security'], 'manage' => '/account/security', ],
The plugin answers with absolute URLs, for example {"enroll": "https://example.com/account/security", "manage": "https://example.com/account/security"}. A key left null is omitted; with both null, or enabled set to false, the URL answers 404. The route sits at the root, outside urlPrefix, and needs no sign-in. A URL array points into your app unless it names a plugin or prefix.
Security
- CSRF. Leave your CSRF protection on for the plugin's routes.
endpointsMeta()hands the token to the JavaScript, which sends it with every request.renameanddeletecarry no WebAuthn challenge, so exempting them would open them to forged requests. - Origin. Set
allowedOrigins. See the table above for what is accepted without it. - Deactivated users. A sign-in is refused when the user row is gone or
users.activeColumnis false, even though the passkey itself is still valid. - Rate limiting. On by default: a fixed window per IP or user on every endpoint, counted in the plugin's
passkeys_rate_limitcache config (a copy ofcache). If the count cannot be stored, the request is refused. A full bucket answers 429. Counting is atomic only on engines withincrement()support (Redis, Memcached, APCu). Bind your ownRateLimiterInterfaceto use your app's limiter instead. Behind a reverse proxy, setApp.trustProxyso the client IP is the visitor's, or every visitor shares one sign-in bucket of 20 per minute. - Email hint. With
login.emailHintenabled,login/startreturns the credential ids of the account behind an email address. That tells anyone whether the address has passkeys. Leave it off unless you need sign-in with security keys that hold no account information. - User verification.
ceremony.userVerificationdefaults torequired, so a sign-in proves a PIN or biometric check. The session flagCakePasskeys.mfa_satisfiedis only set in that case, and removed on a sign-in without it. Clear it in your logout too; the plugin does not see the logout. - Replay. A login challenge is consumed with
Cache::add(), which is only atomic on Redis, Memcached and APCu. With the File engine two finishes racing on the same challenge can both pass the cache check (the session-bound challenge list and the signature counter still apply). Pointcacheat an atomic engine in production. Security.salt. The default user handle is an HMAC of the user id with the salt. Changing the salt makes every existing passkey unusable. If you must rotate it, setusers.userHandleto keep the old handles.- Authorization. With
cakephp/authorization, the plugin marks its own actions as checked, because they verify the user themselves. Request-level policies (a request policy, TinyAuth) still run before them; allow the plugin routes there. - Authenticator allowlist.
allowedAaguidslimits new passkeys to the listed AAGUIDs (case-insensitive UUIDs, for example['cb69481e-8ff7-4039-93ec-0a2729a154a8']for a YubiKey 5). A registration from any other model, or one that reports no AAGUID (all zeros), is refused with a 400. Passkeys stored before the list was set keep working. The AAGUID is reported by the authenticator itself: with the default attestationnonenothing proves it, and a modified or software authenticator can claim any value. Treat the list as a policy filter that keeps honest users on the devices you support, not as proof of the hardware. The plugin does not verify attestation statements; see docs/SecurityModel.md. - Synced passkeys from iCloud Keychain or Google Password Manager report a signature counter of 0. That is accepted. A counter that goes backwards is refused.
More in docs/SecurityModel.md. Report vulnerabilities as described in SECURITY.md.
Moving existing passkeys
If your app stored passkeys before it used this plugin, the authenticators know those users by the handle your old code chose, and a sign-in is checked against it. Keep that handle with users.userHandle:
'users' => [ // the old code used the packed integer id 'userHandle' => fn (\Cake\Datasource\EntityInterface $user): string => pack('J', $user->get('id')), ],
New passkeys then get the same handle. Existing EdDSA passkeys (common on hardware keys) sign in as long as PHP has the sodium extension; new passkeys are created with ES256 or RS256.
If your app already has a passkeys table, do not run the plugin's migration. Add any column the plugin's config/Migrations/*_CreatePasskeys.php has and yours lacks in a migration of your own (keep the binary types for credential_id, public_key and aaguid), then record the plugin migration as done with bin/cake migrations mark_migrated -p CakePasskeys.
That includes the two nullable boolean columns backup_eligible and backed_up from *_AddBackupStateToPasskeys.php. If you marked the plugin migrations as done before that migration existed, add the columns yourself and mark it as well, or run bin/cake migrations migrate -p CakePasskeys, which adds them to your passkeys table.
Recovery
The plugin has no recovery flow. A user who loses every device with a passkey needs another way in, such as a password or a sign-in link by email. Keep one.
Lost devices
Support staff can list and remove a user's passkeys from the shell:
bin/cake passkeys list 42 # id, name, created, last used, authenticator bin/cake passkeys revoke 42 7 # delete passkey 7 of user 42 bin/cake passkeys revoke 42 # delete all of them, after a prompt bin/cake passkeys revoke 42 --yes # the same without the prompt
Pruning unused passkeys
Passkeys of lost or replaced devices pile up. passkeys prune removes the ones nobody has used for a given number of days. A passkey that was never used counts from its creation date.
bin/cake passkeys prune --unused-for 365 --dry-run # list what would go bin/cake passkeys prune --unused-for 365 # delete them, after a prompt bin/cake passkeys prune --unused-for 365 --yes # the same without the prompt, for cron
The last passkey of a user is never pruned, so a cron job cannot lock anybody out: if all of a user's passkeys are unused, the most recently used (or created) one stays. Add --include-last to delete those too.
Each deletion by revoke or prune dispatches CakePasskeys.afterDelete with the command as subject. If the user row is gone, the passkey is still deleted and the user handle in the payload is an empty string. beforeDelete is not dispatched.
Limitations
- No attestation verification.
allowedAaguidsfilters on the AAGUID the authenticator reports, which is not proven.