Passkeys (WebAuthn) as a first factor of omnisense/omniauth, under any PHP host, over web-auth/webauthn-lib 5: the request and creation options a page's script hands the browser, and the assertion and attestation the browser posts back, checked by webauthn-lib with nothing turned off. User verification is required at every ceremony, so no second factor follows a passkey (Marco's decision QA1). The records are those of web-auth/webauthn-symfony-bundle, both ways: a passkey the bundle registered signs in here, and one registered here is read by the bundle. It calls no service: the ceremonies happen between the server, the browser and the authenticator.
new PasskeyMethod($accounts, $passkeys, new RelyingParty(id: 'example.org'));
This package was omniauth/passkey until 2026-10-23 (renamed: it implements WebAuthn, passkeys
are one use of it). It replaces omniauth/passkey, and the former namespace Omniauth\Passkey\
stays an alias of Omniauth\WebAuthn\ for one release (Legacy/, each with a deprecation notice).
The factor keeps its name, passkey (Factor::Passkey).
Installation
composer require omnisense/omniauth omniauth/webauthn
Requires PHP 8.2 with ext-openssl, omnisense/omniauth (1.x), web-auth/webauthn-lib (^5.3),
symfony/serializer and symfony/uid (6.4, 7 or 8: standalone components, webauthn-lib's own
ground). No framework. Under omnibridge/symfony and omnibridge/laravel the passkey key turns it on
(Hosts).
Options
new PasskeyMethod(...):
| Argument | Type | Default | |
|---|---|---|---|
accounts |
Omniauth\Contract\Accounts |
required | the handle (the credential's user id), identifier and display name |
passkeys |
Contract\Passkeys |
required | the passkeys, as PasskeyRecord values (omnibase: userPasskey) |
rp |
RelyingParty |
new RelyingParty() |
who the passkeys belong to |
timeout |
?int |
null |
milliseconds the browser gives the person; null: the browser's own |
new RelyingParty(...), read-only:
| Argument | Type | Default | |
|---|---|---|---|
id |
?string |
null: each request's host, lower case, without its port |
the registrable domain the passkeys belong to (RELYING_PARTY_ID); a passkey made for it serves every host under it |
name |
?string |
null: the id |
what some authenticators show |
origins |
list<string> |
[]: the request's own origin |
the origins a ceremony may come from (a site and its twin) |
insecure |
list<string> |
['localhost'] |
the ids allowed over plain HTTP (WebAuthn's own exception) |
serves($context) is true when the request's host is the id or under it; a host it does not serve
is refused before any challenge (refused, reason relying-party). Constants:
PasskeyMethod::CHALLENGE_LENGTH (32 bytes), PasskeyMethod::ALGORITHMS ([-7, -257]: ES256,
RS256).
Capabilities
a first factor, begin() then verify() (Method\Challenging) |
yes: the request options (no credential listed: a discoverable passkey, the person picks it), then the assertion |
set up from the settings (Method\Enrolling) |
yes: the creation options (the account's passkeys excluded), then the attestation, with a label |
turned off through revoke() (Method\Revocable) |
no: removing a passkey is the store's (the settings page's) |
| a second factor after it | no (QA1): EnrolledSecondFactors::AFTER leaves passkey out |
| attestation formats | none only, asked and accepted (what the bundle does without metadata) |
| algorithms | ES256, RS256 |
| user verification | required at both ceremonies |
| the bundle's rows read and written | yes (The records and the bundle) |
serializer() gives the Symfony serializer webauthn-lib reads and writes its JSON with.
What a ceremony checks
webauthn-lib does the checking; PasskeyMethod asks it for everything and turns nothing off:
- one challenge per ceremony (32 random bytes), kept in the attempt (never in the answer) and
taken at the verification: an assertion posted twice finds nothing to check against (
expired); - the origin among those allowed (the request's own unless
RelyingParty::$originsnames them), the relying party's id hashed in the authenticator data, the client data's type; - user verification required at the sign-in and at the registration;
- the signature with the public key kept; the counter: one that goes back (a cloned
authenticator) is refused; the backup flags, consistent, and saved after each sign-in with the
counter (
Passkeys::used()); - at a registration: ES256 or RS256, a discoverable credential (
residentKey: required), the account's passkeys excluded, a credential already known refused.
Hosts
omnibridge/symfony (omniauth.yaml) |
omnibridge/laravel (config/omniauth.php) |
|
|---|---|---|
| on | passkey: ~ |
'passkey' => [...] |
| the relying party | passkey.rp_id, rp_name, origins |
the same keys |
timeout |
passkey.timeout |
the same |
| the remember-me after a passkey | passkey.remember (true; the posted JSON's remember wins) |
the same |
| the request options | POST passkey.options_path (/login/passkey/options), route passkey_login_options |
the same |
| the assertion | POST passkey.path (/login/passkey), route passkey_login |
the same |
| the store | stores.passkeys (Omniauth\WebAuthn\Contract\Passkeys) |
stores.passkeys, or omnibase's Base\Auth\UserPasskeys |
Both answer in the JSON web-auth/webauthn-symfony-bundle answered, so a page's script stays as it
is: the options as {"status": "ok", "errorMessage": "", ...the options}, a sign-in as
{"status": "ok", "errorMessage": ""} (with redirect when a second factor follows), a refused sign-in as
{"status": "failed", "errorMessage": ...} with HTTP 401, options that could not be given with
HTTP 400. Registering a passkey from the settings
is the application's page (omnibase's), through $omniauth->enrol(Factor::Passkey, $account).
Errors
The failures and their reasons are listed in The method: expired,
bad-credentials (unreadable-credential, unknown-passkey, invalid-assertion,
invalid-attestation, known-passkey), refused (relying-party). webauthn-lib's own message
is never shown. The exceptions:
| Exception | When |
|---|---|
\UnexpectedValueException |
a PasskeyRecord whose credential id or public key is not base64 (a store's mistake) |
\JsonException |
PasskeyRecord::fromRow() of a JSON column that is not JSON |
\InvalidArgumentException, \LogicException |
the test doubles: a SoftwareAuthenticator key that is not P-256; InMemoryPasskeys::add() of a record for another account |
Verified, and not
Run in omniauth's harness on 2026-10-25 (PHP 8.4, doctrine/dbal 4 and
web-auth/webauthn-symfony-bundle 5.3 installed beside): omniauth/webauthn's 34 tests green. They
cover a registration from the settings then a sign-in through the conductor, by the software
authenticator, and no second factor after it (QA1); the options' JSON; every refusal (a forged
signature, an unknown passkey, a challenge used twice, another challenge, another origin, another
relying party, a host the relying party does not serve, a person not verified, a counter gone back,
the same authenticator registered twice, an unreadable credential); the backup flags saved; one
relying party for two hosts (QA9), and localhost over HTTP; both ways with the bundle
(Tests/BundleCompatibilityTest: a row the bundle wrote signs in here, a row written here is read
and verified by the bundle as it configures webauthn-lib); docs/installation.md run as written;
in plain PHP (BareTest), nothing loaded but omniauth, webauthn-lib and what it requires.
Not verified: a real browser and a real authenticator (Chrome's virtual authenticator, at steps AU7
and AU8 of the omniauth plan); attestation formats other than none and metadata statements (not
asked); a host's store and the bridges' answers around the JSON, tested in their own suites.
Proposer une modification docs/index.md
Installation
composer require omnisense/omniauth omniauth/webauthn
It requires omnisense/omniauth and web-auth/webauthn-lib (framework-free: its CBOR, COSE and PKI
libraries, and the standalone Symfony components it is written on - serializer, property-info,
uid, clock). No bundle, no framework: the passkeys stay in your table, behind
Omniauth\WebAuthn\Contract\Passkeys, as PasskeyRecord values - the 13 fields of WebAuthn's
credential record, as omnibase's userPasskey table holds them.
The whole thing by hand - the in-memory stores of Omniauth\Testing stand in for yours, and
Omniauth\WebAuthn\Testing\SoftwareAuthenticator for the person's browser and device:
<?php
require 'vendor/autoload.php';
use Omniauth\ArrayState;
use Omniauth\Context;
use Omniauth\Factor;
use Omniauth\Method\Methods;
use Omniauth\Omniauth;
use Omniauth\WebAuthn\PasskeyMethod;
use Omniauth\WebAuthn\RelyingParty;
use Omniauth\WebAuthn\Testing\InMemoryPasskeys;
use Omniauth\WebAuthn\Testing\SoftwareAuthenticator;
use Omniauth\Testing\InMemoryStore;
use Omniauth\Testing\TestAccount;
$store = new InMemoryStore($ringo = new TestAccount('42', 'ringo', 'ringo@example.org', name: 'Ringo Starr'));
$passkeys = new InMemoryPasskeys($store); // yours: the userPasskey table
$session = []; // $_SESSION, in a real page
$omniauth = new Omniauth(
accounts: $store,
methods: new Methods([
new PasskeyMethod($store, $passkeys, new RelyingParty(id: 'example.org')), // RELYING_PARTY_ID
]),
state: new ArrayState($session),
);
$context = new Context(origin: 'https://example.org', host: 'example.org', ip: '203.0.113.7');
$browser = new SoftwareAuthenticator('https://example.org');
// From the account's settings: the creation options, then what the browser made of them.
$enrolment = $omniauth->enrol(Factor::Passkey, $ringo);
$options = $enrolment->begin($context); // JSON for PublicKeyCredential.parseCreationOptionsFromJSON()
$credential = $browser->create($options->payload); // navigator.credentials.create(), then credential.toJSON()
echo $enrolment->confirm($context, ['json' => json_encode($credential), 'label' => 'MacBook'])->isDone() ? "registered\n" : "refused\n";
// registered
// Signing in: the request options, then the assertion. No account named: the person picks the passkey.
$options = $omniauth->begin('passkey', $context); // JSON for PublicKeyCredential.parseRequestOptionsFromJSON()
$assertion = $browser->get($options->payload); // navigator.credentials.get(), then credential.toJSON()
$result = $omniauth->verify('passkey', $context, ['json' => json_encode($assertion)]);
echo $result->status->value, ' as ', $result->account->identifier, ' by ', implode(' + ', array_map(fn ($f) => $f->value, $result->passed)), "\n";
// authenticated as ringo by passkey
What your page does around it is the host's part: it sends $options->payload as it is (the
bridges add status: "ok" and errorMessage: "" around it, as web-auth/webauthn-symfony-bundle
does, for assets/security/webauthn.js), posts the browser's JSON back, checks the CSRF token of
the settings page, and on Authenticated renews its session id and keeps the account's handle.
No second factor follows a passkey (Marco's decision QA1): user verification is required at every ceremony - the device, and the gesture or the code that unlocks it.
Run in omniauth's harness on 2026-10-25 (the block as it is, Tests/DocsTest.php), it prints:
registered
authenticated as ringo by passkey
Next: the method, the records and the bundle.
Proposer une modification docs/installation.md
The method
new PasskeyMethod(
accounts: $accounts, // Omniauth\Contract\Accounts
passkeys: $passkeys, // Omniauth\WebAuthn\Contract\Passkeys
rp: new RelyingParty(
id: 'example.org', // RELYING_PARTY_ID; null: each request's host
name: 'Les Ringo Stars', // what some authenticators show; null: the id
origins: [], // the origins allowed; []: the request's own
insecure: ['localhost'], // the ids allowed over plain http
),
timeout: null, // milliseconds the browser gives the person; null: its own
);
A first factor. No second factor follows it (QA1): user verification is required at every ceremony.
Signing in
| Step | Call | Gives | Keeps in the attempt |
|---|---|---|---|
| options | $omniauth->begin('passkey', $context) |
Challenge::json(): challenge, rpId, allowCredentials: [], userVerification: "required" |
the challenge, the id |
| assertion | $omniauth->verify('passkey', $context, ['json' => $posted]) |
Result (Authenticated, passed: [passkey]) |
nothing: the challenge is taken |
The proof is what the browser's credential.toJSON() gives, under json - or its keys as the
proof itself. The passkey is found by its id (Passkeys::byCredentialId(base64)), its account by
its user handle (Accounts::byHandle()); after the check, Passkeys::used() receives the record
with its counter and backup flags moved.
Registering, from the settings
| Step | Call | Gives |
|---|---|---|
| options | $omniauth->enrol(Factor::Passkey, $account)->begin($context) |
Challenge::json(): rp, user (id: the handle, name: the identifier, displayName), challenge, pubKeyCredParams (ES256, RS256), authenticatorSelection (verification and resident key required), attestation: "none", excludeCredentials (the account's passkeys) |
| attestation | ->confirm($context, ['json' => $posted, 'label' => '...']) |
Enrolled: done, or failed with its reason; the record added by Passkeys::add($account, $record, $label) |
Refusals
| Failure | Reason | When |
|---|---|---|
expired |
- | no options begun in this attempt, or already used |
bad-credentials |
unreadable-credential |
the JSON is not a credential of the expected kind |
unknown-passkey |
no passkey of that id, or its account is gone | |
invalid-assertion |
webauthn-lib refused: challenge, origin, relying party, presence, verification, signature, counter, backup flags | |
invalid-attestation |
webauthn-lib refused the registration | |
known-passkey |
this credential is already registered | |
refused |
relying-party |
the request's host is not under the relying party's id |
The conductor throttles both steps (passkey, passkey.begin) like every method.
What the host does
Send Challenge::$payload as it is (the bridges wrap it as web-auth/webauthn-symfony-bundle did,
status: "ok", errorMessage: "", so assets/security/webauthn.js stays as it is); post the
browser's JSON back; check the settings page's CSRF token before a registration; on
Authenticated, renew the session id.
Proposer une modification docs/webauthn.md
The records and the bundle
A PasskeyRecord is a passkey as omnibase's userPasskey table holds it: the 13 fields of
webauthn-lib's CredentialRecord, under the same names, in the values of the columns.
| Field | Column's text | In the record |
|---|---|---|
publicKeyCredentialId |
base64 (standard, padded) of the id's bytes | the same base64; credentialId() gives the bytes |
type |
public-key |
the same |
transports |
JSON, ["internal"] |
a list |
attestationType |
none |
the same |
trustPath |
JSON, [] or {"x5c":[...]} |
the array webauthn-lib normalizes it to |
aaguid |
a UUID's text | the same |
credentialPublicKey |
base64 of the COSE key | the same base64; publicKey() gives the bytes |
userHandle |
the account's id, as text | the same |
counter |
a number | an int |
otherUI |
JSON or null | an array or null |
backupEligible, backupStatus, uvInitialized |
0/1 or null | bools or null |
That text is what the bundle's DBAL types (base64, aaguid, trust_path, json) write, and
what omnibase's own (passkey_base64, passkey_aaguid, passkey_trust_path, step AU1) reproduce:
$record = PasskeyRecord::fromRow($row); // a row as the database gives it back (other columns ignored)
$row = $record->toRow(); // the 13 columns as the bundle writes them
$credential = $record->toCredentialRecord(); // webauthn-lib's object, as omnibase's PasskeyRepository::credentialRecord() makes it
$record = PasskeyRecord::fromCredentialRecord($credential); // as PasskeyRepository::fill() copies it
An application on Doctrine (omnibase) maps its entity's fields instead: the entity holds the bytes
(its passkey_base64 type decodes them), so its store gives base64_encode() of them.
Both ways
So that the switch from the bundle (lesringostars at step AU7, then every site) loses no passkey,
Tests/BundleCompatibilityTest checks:
- registered by the bundle, signed in here: the row lesringostars' sign-in suite saw the bundle write when a passkey was added from the settings (AU1), recorded as it is; and live, the row the bundle's DBAL types write for the record webauthn-lib's attestation check makes - the same row;
- registered here, read by the bundle: the row
PasskeyMethodmade, read through the bundle's DBAL types into theCredentialRecordits repository hands its authenticator, then a sign-in checked by webauthn-lib as the bundle configures it; what the bundle saves back equals what omniauth writes after the same sign-in.
One difference stays visible: the bundle asked userVerification: "preferred" (its default), this
method asks "required" at both ceremonies. A passkey registered by the bundle without verification
(uvInitialized false) signs in here only if the authenticator verifies the person this time.