Install with Composer:
composer require ophelios/php-webauthn-passkey
Requirements: PHP >= 8.4
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.
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;<?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()));
}
}<?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 the following exception pattern to the CSRF middleware in your config.yml file for a Zephyrus-based project.
security:
csrf:
enabled: true
exceptions: ['\/webauthn.*']
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
}- 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.