Search by

dereuromark / cakephp-passkeys

dereuromark

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!

dereuromark

Statistics

Installs: 26

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0


README

CI JS E2E PHPStan Latest Stable Version Minimum PHP Version License Total Downloads Coding Standards

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://localhost counts as one for development.
  • A cache engine that keeps data between two requests (not Null or Array) 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:

  1. renews the session id
  2. signs the user in (see below)
  3. dispatches CakePasskeys.afterLogin
  4. answers with {"redirectTo": ..., "mfaSatisfied": ..., "userId": ...} plus any keys an afterLogin listener added, and the JavaScript navigates to redirectTo

How the user gets signed in:

  • With cakephp/authentication loaded, the plugin loads the user row from users.table and passes it to the authentication service's persistIdentity(). 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.id next to the Session authenticator's Auth). Set session.userIdKey to an empty string if nothing reads it.
  • session.persistIdentity set to false turns off the hand-over; then only session.userIdKey is written.

Who is signed in

Registering, renaming, deleting and re-confirming need the current user. The plugin looks in this order:

  1. CakePasskeys.identityResolver, if set:

    'identityResolver' => fn (\Cake\Http\ServerRequest $request) => $request->getAttribute('authUser')?->id,
  2. The identity request attribute, as set by cakephp/authentication. Objects with getIdentifier(), objects with an id property and arrays with an id key all work.

  3. 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.php
  • templates/plugin/CakePasskeys/email/text/registered.php
  • templates/plugin/CakePasskeys/layout/email/html/default.php (and text/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. rename and delete carry 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.activeColumn is 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_limit cache config (a copy of cache). If the count cannot be stored, the request is refused. A full bucket answers 429. Counting is atomic only on engines with increment() support (Redis, Memcached, APCu). Bind your own RateLimiterInterface to use your app's limiter instead. Behind a reverse proxy, set App.trustProxy so the client IP is the visitor's, or every visitor shares one sign-in bucket of 20 per minute.
  • Email hint. With login.emailHint enabled, login/start returns 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.userVerification defaults to required, so a sign-in proves a PIN or biometric check. The session flag CakePasskeys.mfa_satisfied is 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). Point cache at 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, set users.userHandle to 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. allowedAaguids limits 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 attestation none nothing 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. allowedAaguids filters on the AAGUID the authenticator reports, which is not proven.