ophelios / php-webauthn-passkey
Simple WebAuthn library for PHP to ease passkey creation and authentication.
Installs: 15
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 1
Open Issues: 1
pkg:composer/ophelios/php-webauthn-passkey
Requires
- php: >=8.4
- phpdocumentor/reflection-docblock: ^5.3.0
- symfony/property-access: ^v7.3.1
- symfony/property-info: ^v7.3.1
- symfony/serializer: ^v7.3.1
- web-auth/webauthn-lib: ^4.9.2
Requires (Dev)
- phpunit/phpunit: ^10.5
README
💿 Installation and dependencies
Install with Composer:
composer require ophelios/php-webauthn-passkey
Requirements: PHP >= 8.4
Add the required table into your database. The example below if for PostgreSQL:
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE account.passkeys
(
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INT NOT NULL REFERENCES <YOUR USER ID REFERENCE> ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE, -- raw credentialId (binary)
public_key_cose BYTEA NOT NULL, -- COSE-encoded public key
sign_count BIGINT NOT NULL DEFAULT 0,
backup_eligible BOOLEAN NOT NULL DEFAULT false,
prf_salt BYTEA, -- 32-byte per-passkey salt (used for deterministic PRF seed derivation)
transports TEXT, -- e.g. "internal,usb,nfc"
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_used_at TIMESTAMPTZ
);
Replace the <YOUR USER ID REFERENCE> with the column name of your user identifier.
The identifier column if of type UUID given by gen_random_uuid() which is included in the extension pgcrypto.
You can enable it with CREATE EXTENSION IF NOT EXISTS "pgcrypto"; as shown above.
🌱 Usage
Create the broker instance
First, create the broker instance you will use to interact with the database.
<?php namespace Models\Account\Brokers; use Passkey\Passkey; use Passkey\PasskeyBrokerInterface; use stdClass; use Zephyrus\Database\DatabaseBroker; class PasskeyBroker extends DatabaseBroker implements PasskeyBrokerInterface { public function findUserIdByCredentialId(string $credentialId): ?int { $sql = "SELECT user_id FROM account.passkeys WHERE credential_id = ? LIMIT 1"; $row = $this->selectSingle($sql, [bin2hex($credentialId)]); return $row?->user_id ?? null; } public function findByCredentialId(string $credentialId): ?stdClass { $sql = "SELECT * FROM account.passkeys WHERE credential_id = ? LIMIT 1"; return $this->selectSingle($sql, [bin2hex($credentialId)]); } public function findAllByUserId(string $userId): array { $sql = "SELECT * FROM account.passkeys WHERE user_id = ?"; return $this->select($sql, [$userId]); } public function findUserIdentity(string $userId): stdClass { $sql = "SELECT email, fullname AS display_name FROM account.view_user_profile WHERE id = ?"; return $this->selectSingle($sql, [$userId]); } public function updateUsageAndCounter(string $credentialId, int $newSignCount): void { $this->query("UPDATE account.passkeys SET sign_count = ?, last_used_at = now() WHERE credential_id = ?", [ $newSignCount, bin2hex($credentialId) ]); } public function insert(Passkey $passkey): void { $sql = "INSERT INTO account.passkeys (user_id, credential_id, public_key_cose, sign_count, backup_eligible, prf_salt, transports) VALUES (?, decode(?, 'hex'), decode(?, 'hex'), ?, ?, decode(?, 'hex'), ?)"; $this->query($sql, [ $passkey->user_id, bin2hex($passkey->credential_id), bin2hex($passkey->public_key_cose), $passkey->sign_count, $passkey->backup_eligible, bin2hex($passkey->prf_salt ?? ''), $passkey->transports ]); } }
If you already have a table and want to use the PRF extension, add the column:
ALTER TABLE account.passkeys ADD COLUMN prf_salt BYTEA;
Create your registration Controller
<?php namespace Controllers\Application; use Models\Account\Services\WebAuthnService; use Zephyrus\Network\Response; use Zephyrus\Network\Router\Post; class WebAuthnController extends AppController { #[Post("/webauthn/register/options")] public function options(): Response { $service = new PasskeyService(); return $this->json($service->options(Passport::getUserId())); } #[Post("/webauthn/register/verify")] public function verify(): Response { $service = new PasskeyService(); return $this->json($service->verify(Passport::getUserId())); } }
Create your authentication Controller
<?php namespace Controllers\Public; use Controllers\Controller; use Models\Account\Services\WebAuthnService; use Zephyrus\Network\Response; use Zephyrus\Network\Router\Post; class WebAuthnController extends Controller { #[Post("/webauthn/login/options")] public function options(): Response { $service = new WebAuthnService(); return $this->json($service->assertionOptions()); } #[Post("/webauthn/login/verify")] public function verify(): Response { $service = new WebAuthnService(); return $this->json($service->authenticate()); } }
Add routes exception to the CSRF middleware
Add the following exception pattern to the CSRF middleware in your config.yml file for a Zephyrus-based project.
security: csrf: enabled: true exceptions: ['\/webauthn.*']
Front-end module (ESM) for passkey registration and login
We provide an ES module you can use to handle both Passkey registration (create) and authentication (login) with configurable endpoints.
- Module file: backpack/public/javascripts/modules/passkey.js
Registration (create) example with callbacks:
<button id="createPasskeyBtn">Create a Passkey</button> <script type="module"> import { initPasskeyRegistration } from '/javascripts/modules/passkey.js'; initPasskeyRegistration({ buttonSelector: '#createPasskeyBtn', optionsUrl: '/webauthn/register/options', verifyUrl: '/webauthn/register/verify', // Experimental PRF (disabled by default) prf: { enabled: true }, onSuccess: () => { // e.g., show a toast or update UI console.log('Passkey created successfully'); }, onError: (err) => { console.error('Registration failed:', err); } }); </script>
Login (assertion) example with callbacks:
<button id="btn-passkey-login">Login with Passkey</button> <script type="module"> import { initPasskeyLogin } from '/javascripts/modules/passkey.js'; initPasskeyLogin({ buttonSelector: '#btn-passkey-login', optionsUrl: '/webauthn/login/options', verifyUrl: '/webauthn/login/verify', // Experimental PRF (disabled by default) prf: { enabled: true }, onSuccess: () => { // e.g., redirect or update UI window.location.href = '/'; }, onError: (err) => { console.error('Login failed:', err); } }); </script>
Programmatic usage (no UI binding):
import { registerPasskey, passkeyLogin } from '/javascripts/modules/passkey.js'; // Registration const reg = await registerPasskey({ optionsUrl: '/webauthn/register/options', verifyUrl: '/webauthn/register/verify' }); if (!reg.ok) { console.error(reg.err); } // Authentication const auth = await passkeyLogin({ optionsUrl: '/webauthn/login/options', verifyUrl: '/webauthn/login/verify', prf: { enabled: true } // experimental }); if (auth.ok) { // success }
Experimental: PRF-based deterministic seed (opt-in)
- Server exposes a site-scoped PRF input salt when enabled. Instantiate the service with
enablePrf: true:
$service = new Passkey\PasskeyService($provider, rpName: 'Your App', enablePrf: true);
-
During registration, a per-passkey 32-byte random salt is generated and stored in
prf_salt. -
Clients that opt-in to PRF (
prf: { enabled: true }) will request PRF from the authenticator and return the PRF output inclientExtensionResults.prfResults. -
After a successful assertion (or attestation), you can derive a deterministic 32-byte seed on the server from the client PRF output and the stored
prf_saltusing:
$seedB64Url = $service->deriveSeedFromPrf($credentialIdRaw, $prfFirstOutputB64Url);
Notes on PRF-derived seed usage:
- Unfortunately, not all authenticators support PRF, as this is a client opt-in extension, you cannot enforce it. I would highly recommend keeping a fallback solution. Currently, only linux-based platform authenticators, macOS/iOS, android and certain authenticator app support PRF.
- The derived PRF output is a stable and cryptographically strong 32-byte material that can be used as a
seedfor encryption. It is not a secret by itself. And thus, cannot and should not be used as one.