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.

Maintainers

Package info

github.com/OvenLab/cakephp-passkey

Homepage

Issues

Type:cakephp-plugin

pkg:composer/ovenlab/cakephp-passkey

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

v0.1.0 2026-08-19 15:47 UTC

This package is auto-updated.

Last update: 2026-08-19 15:52:51 UTC


README

CI Latest Stable Version License

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.

  • RegistrationPOST /passkey/register/options + /register/verify
  • LoginPOST /passkey/login/options + /login/verify (usernameless / discoverable credentials supported)
  • ManagementGET /passkey/credentials, DELETE /passkey/credentials/{id}
  • Authentication adapterPasskeyAuthenticator + PasskeyIdentifier
  • Browser clientwebroot/js/passkey.js, vanilla, no dependencies

Table of contents

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/serializer 7)
  • CakePHP 5.0+
  • ext-json (and ext-sodium / ext-mbstring recommended)
  • HTTPS — WebAuthn only works in a secure context; http://localhost is 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 CsrfProtectionMiddleware sets an httponly cookie by default, so JavaScript cannot read the token from document.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/authentication directly. Covers both the controller-driven setIdentity path (the default) and the middleware PasskeyAuthenticator + PasskeyIdentifier adapter, 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.id and origin in 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 /verify whether verification succeeds or fails.
  • Keep config/passkey.php out of version control if it carries anything environment-specific; the example uses env() throughout.

Running the tests

composer install
composer test        # phpunit
composer cs-check    # phpcs
composer stan        # phpstan

License

MIT. See LICENSE.