ovenlab / cakephp-passkey
Passwordless and second-factor authentication for CakePHP 5 using WebAuthn passkeys: registration/assertion ceremonies, credential storage, an Authentication adapter, a browser helper and vanilla JS.
Package info
github.com/OvenLab/cakephp-passkey
Type:cakephp-plugin
pkg:composer/ovenlab/cakephp-passkey
Requires
- php: >=8.2
- ext-json: *
- cakephp/cakephp: ^5.0
- web-auth/webauthn-lib: ^5.3
Requires (Dev)
- cakephp/authentication: ^3.0
- cakephp/cakephp-codesniffer: ^5.0
- cakephp/migrations: ^4.0
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.1
Suggests
- cakephp/authentication: To plug passkeys into the Authentication middleware via the bundled Authenticator and Identifier.
README
Passwordless and second-factor authentication for CakePHP 5 using WebAuthn passkeys.
Built on top of web-auth/webauthn-lib
5.x, this plugin gives you the full registration and login ceremonies,
credential storage in your database, a browser client with zero build step, and
a drop-in adapter for cakephp/authentication.
- Registration —
POST /passkey/register/options+/register/verify - Login —
POST /passkey/login/options+/login/verify(usernameless / discoverable credentials supported) - Management —
GET /passkey/credentials,DELETE /passkey/credentials/{id} - Authentication adapter —
PasskeyAuthenticator+PasskeyIdentifier - Browser client —
webroot/js/passkey.js, vanilla, no dependencies
Table of contents
- How it works — the ceremonies
- Requirements
- Installation
- Configuration
- Architecture
- HTTP API
- Browser client
- Integration guides
- How credentials are stored
- Security notes
- Running the tests
- License
How it works — the ceremonies
A passkey is a public/private key pair bound to your site's domain (the Relying Party) and held by an authenticator — the device's secure hardware (Touch ID, Windows Hello, a security key) or a synced provider (iCloud Keychain, Google Password Manager). The private key never leaves the authenticator; your server only ever stores the public key. Authentication is a signed challenge, so there is no shared secret to phish, leak, or reuse.
WebAuthn defines two ceremonies. Each is a two-step round trip: the server issues options containing a fresh random challenge, the browser drives the authenticator, and the server verifies the signed result. The plugin stores the challenge in the session between the two steps.
Registration (attestation) — enrol a new passkey
The user is already logged in and wants to add a passkey to their account.
sequenceDiagram
participant B as Browser
participant S as Plugin (server)
participant A as Authenticator
B->>S: POST /passkey/register/options
S->>S: build creation options + random challenge
S->>S: store challenge in session
S-->>B: PublicKeyCredentialCreationOptions (JSON)
B->>A: navigator.credentials.create({publicKey})
A->>A: generate key pair, sign challenge (user verification)
A-->>B: attestation (public key + credential id)
B->>S: POST /passkey/register/verify
S->>S: verify attestation against stored challenge + origin
S->>S: persist public key as a passkey_credentials row
S-->>B: { "verified": true }
Loading
Maps to RegistrationService::creationOptions() /
RegistrationService::verify().
Login (assertion) — sign in with a passkey
sequenceDiagram
participant B as Browser
participant S as Plugin (server)
participant A as Authenticator
B->>S: POST /passkey/login/options
S->>S: build request options + random challenge
S->>S: store challenge in session
S-->>B: PublicKeyCredentialRequestOptions (JSON)
B->>A: navigator.credentials.get({publicKey})
A->>A: user verification, sign challenge with private key
A-->>B: assertion (signature + credential id)
B->>S: POST /passkey/login/verify
S->>S: look up stored public key, verify signature + counter
S->>S: establish identity (Authentication) / return user id
S-->>B: { "verified": true, "userId": "..." }
Loading
Maps to AssertionService::requestOptions() / AssertionService::verify().
For the full picture — origins, the signature counter, base64url, usernameless flows and the security properties of each step — read docs/ceremonies.md.
Requirements
- PHP 8.2+ (the WebAuthn serializer requires
symfony/serializer7) - CakePHP 5.0+
ext-json(andext-sodium/ext-mbstringrecommended)- HTTPS — WebAuthn only works in a secure context;
http://localhostis exempt for development
Installation
composer require ovenlab/cakephp-passkey
Load the plugin:
bin/cake plugin load Passkey
// src/Application.php public function bootstrap(): void { parent::bootstrap(); $this->addPlugin('Passkey'); }
Run the migration to create the passkey_credentials table:
bin/cake migrations migrate -p Passkey
Configuration
Copy config/passkey.example.php to your app's config/passkey.php and adjust
it — the plugin loads that file automatically during bootstrap() when it
exists. Never commit secrets — use env():
// config/passkey.php return [ 'Passkey' => [ 'rp' => [ 'name' => 'My App', // shown by the authenticator UI 'id' => env('PASSKEY_RP_ID'), // registrable domain, e.g. "example.com" ], 'origin' => env('PASSKEY_ORIGIN'), // e.g. "https://example.com" 'userVerification' => 'preferred', // 'required' | 'preferred' | 'discouraged' 'residentKey' => 'preferred', // 'required' | 'preferred' | 'discouraged' 'timeout' => 60000, 'usernameless' => true, // allow discoverable-credential login 'mode' => 'passwordless', // 'passwordless' | 'second_factor' 'userModel' => 'Users', // table used to resolve the user on login 'userField' => 'id', // field matched against the credential owner 'displayNameField' => 'username', // shown by the authenticator during registration ], ];
| Key | Default | Purpose |
|---|---|---|
rp.name |
CakePHP Passkey |
Human-readable Relying Party name shown by the authenticator. |
rp.id |
request host | Registrable domain the credential is bound to. Pin it in production. |
origin |
request scheme+host | Expected origin the ceremony must come from. Pin it in production. |
userVerification |
preferred |
Require a PIN/biometric gesture (required for true passwordless). |
residentKey |
preferred |
Ask the authenticator to store a discoverable credential. |
timeout |
60000 |
Ceremony timeout in milliseconds. |
usernameless |
true |
Allow login to start without a username (discoverable credential). |
mode |
passwordless |
Documents intent; second_factor = passkey on top of a password. |
userModel |
Users |
Table the controller loads to resolve the user id → identity on login. |
userField |
id |
Column matched against the credential's stored user_id. |
displayNameField |
username |
Field used as the WebAuthn user display name during registration. |
When rp.id / origin are omitted they are derived from the incoming request.
That is convenient in development, but pinning them in production is strongly
recommended so the Relying Party identity cannot drift with the Host header.
Architecture
The plugin is a thin, request-agnostic core with a controller and adapters on top, so you can consume it at whichever layer fits your app.
| Layer | Class | Responsibility |
|---|---|---|
| WebAuthn wrapper | Service\WebAuthnService |
(De)serializer, ceremony validators, RP entity, base64url helpers. |
| Registration | Service\RegistrationService |
Build creation options; verify attestation; persist the credential. |
| Login | Service\AssertionService |
Build request options; verify assertion; bump the counter. |
| HTTP | Controller\PasskeyController |
JSON endpoints; session challenge; optional setIdentity on login. |
| Storage | Model\Table\PasskeyCredentialsTable + entity |
passkey_credentials rows (the serialized CredentialRecord). |
| Auth middleware | Authenticator\PasskeyAuthenticator + Identifier\PasskeyIdentifier |
Verify + resolve the user inside the authentication middleware. |
| View / client | View\Helper\PasskeyHelper + webroot/js/passkey.js |
Emit the script; run the ceremonies in the browser. |
The services take the resolved origin and rpId as explicit arguments and
never read the request, which keeps them easy to unit-test and reusable outside
a controller.
HTTP API
All endpoints exchange JSON. The register and management endpoints require an
authenticated user (they act on the current identity); the login endpoints are
public. The challenge issued by an /options call is stored in the session and
consumed — once — by the matching /verify call.
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST |
/passkey/register/options |
yes | Issue creation options for the user |
POST |
/passkey/register/verify |
yes | Verify attestation, store the credential |
POST |
/passkey/login/options |
no | Issue request options |
POST |
/passkey/login/verify |
no | Verify assertion, establish the identity |
GET |
/passkey/credentials |
yes | List the current user's credentials |
DELETE |
/passkey/credentials/{id} |
yes | Delete one of the user's credentials |
/register/verify accepts an optional name alongside the credential so users
can label a passkey ("My laptop"). /login/verify returns the resolved
userId; when the Authentication component is loaded it also calls
setIdentity() so the user is logged in (see the integration guides).
Example — the verify request body the browser client sends:
{
"name": "My laptop",
"credential": {
"id": "…",
"rawId": "…base64url…",
"type": "public-key",
"response": { "clientDataJSON": "…", "attestationObject": "…" }
}
}
Browser client
Include the client with the helper and drive the ceremonies. The script exposes
a global window.Passkey:
// in a template — loads webroot/js/passkey.js <?= $this->Passkey->script() ?>
<button id="register">Add a passkey</button> <button id="login">Sign in with a passkey</button> <script> const csrfToken = '<?= h($this->request->getAttribute('csrfToken')) ?>'; document.getElementById('register').addEventListener('click', async () => { if (!Passkey.isSupported()) { alert('This browser does not support passkeys.'); return; } await Passkey.register({ csrfToken, name: 'My laptop' }); location.reload(); }); document.getElementById('login').addEventListener('click', async () => { const result = await Passkey.login({ csrfToken }); if (result.verified) { location.href = '/'; } }); </script>
Passkey.register(opts) and Passkey.login(opts) handle the base64url encoding
and call the plugin endpoints. Options:
| Option | Type | Notes |
|---|---|---|
csrfToken |
string | Sent as the X-CSRF-Token header. Required when CSRF protection is on. |
name |
string | register() only — a human label stored with the passkey ("My laptop"). |
baseUrl |
string | Endpoint base path, default /passkey. Set it if you remounted the plugin. |
CSRF: CakePHP's
CsrfProtectionMiddlewaresets an httponly cookie by default, so JavaScript cannot read the token fromdocument.cookie. Pass it explicitly from a template attribute as shown above, rather than relying on the cookie.
Passkey.isSupported() returns whether the browser exposes the WebAuthn API.
Cancellations (the user dismisses the prompt) surface as a rejected promise —
catch it and treat it as "no passkey used", not an error.
Integration guides
The login ceremony needs to turn a verified assertion into a logged-in session. There are two ways to wire that up:
- docs/integration-authentication.md —
plug into
cakephp/authenticationdirectly. Covers both the controller-drivensetIdentitypath (the default) and the middlewarePasskeyAuthenticator+PasskeyIdentifieradapter, and when to choose each. - docs/integration-cakedc-users.md — add "sign in with a passkey" to an app already using CakeDC/Users: the config flag, the login template button, the permission entries, and the double-load gotcha to avoid.
How credentials are stored
Each row in passkey_credentials keeps the serialized WebAuthn
CredentialRecord (the source of truth for verification) alongside denormalized
columns for lookups: the base64url credential_id, the owning user_id, the
signature sign_count, transports, aaguid, an optional human name, and
last_used. On login the record is deserialized, verified, and its counter and
last_used are written back.
user_id is a string so it can hold any application key (integer id, UUID, …).
credential_id is uniquely indexed; user_id is indexed for the per-user
lookups used by the management endpoints and re-registration exclusion list.
Security notes
- Serve everything over HTTPS; pin
rp.idandoriginin production so the Relying Party identity cannot be influenced by request headers. - The signature counter is verified and persisted on every assertion, which lets the library detect cloned authenticators.
- Registration and management endpoints act on the current identity only — a user can never enumerate or delete another user's credentials.
- The challenge is single-use: it is deleted from the session on
/verifywhether verification succeeds or fails. - Keep
config/passkey.phpout of version control if it carries anything environment-specific; the example usesenv()throughout.
Running the tests
composer install composer test # phpunit composer cs-check # phpcs composer stan # phpstan
License
MIT. See LICENSE.