farsh4d/webauthn-framework
A Laravel/PHP WebAuthn framework for adding passkey and security-key authentication to your app. It provides server-side registration and login flows for FIDO2/WebAuthn, aiming to simplify passwordless sign-in.
Know what you're installing first. farsh4d/webauthn-framework is a personal copy of web-auth/webauthn-framework 4.9.2 — the de-facto PHP FIDO2/WebAuthn implementation. The library itself is battle-tested; the distribution is not: 0 stars, 0 dependents, no security policy, and "some changes for personal use" means the code may diverge from upstream without a changelog. Treat this as a vendored fork, not a dependency you can composer update blindly.
The freshly published 4.9.3 does not change that verdict. Its changelog is one PR: "Modify package description and update dependency version." No code, no API, no fixes — but the dependency bump is not nothing: it means the fork's composer.json constraints no longer match upstream 4.9.2's, so your transitive graph can drift from what upstream tested. Note also that the fork now publishes a version number shadowing upstream's scheme, which makes advisory/composer outdated tooling misleading (see Gotchas).
Decision rule before you start:
web-auth/webauthn-lib + web-auth/webauthn-symfony-bundle's config as reference, and skip the fork entirely. Same API, maintained, patched.4.9.2 → 4.9.3 just proved that tags can move for metadata-only reasons, and tags are mutable.Minimal install (Laravel). The package is not a Laravel package; the Symfony Bundle half is dead weight here. You want the framework-agnostic core (webauthn-lib), wired by hand.
// composer.json — the fork is unlikely to be on Packagist
"repositories": [
{ "type": "vcs", "url": "https://github.com/farsh4d/webauthn-framework" }
],
"require": {
"farsh4d/webauthn-framework": "dev-main#<commit-sha>" // 4.9.3's commit, or your reviewed SHA
}
Then:
composer show farsh4d/webauthn-framework and open its composer.json. Check whether it's a monorepo (src/webauthn-lib, src/webauthn-symfony-bundle, …) with replace entries, or a flattened single package. This determines your autoload namespace and whether Symfony classes leak into your dependency graph — and it's the file the 4.9.3 release actually touched.git clone it next to your project and diff against web-auth/webauthn-framework tag 4.9.2. Every diff hunk that isn't cosmetic is a behaviour you now own. Additionally run git diff 4.9.2 4.9.3 -- composer.json on the fork to see exactly which dependency constraint moved — that's the only real change in the new release.vendor/ for the exact 4.9.2 signatures — Webauthn\Server, Webauthn\PublicKeyCredentialCreationOptions, Webauthn\PublicKeyCredentialRequestOptions, Webauthn\AuthenticatorSelectionCriteria. Upstream docs at https://web-auth.github.io/ apply ~1:1, which is the real value of a 4.9.2 copy.config/webauthn.php (rp id, rp name, allowed origins, timeout).Webauthn\Server singleton in a service provider, plus an adapter implementing Webauthn\PublicKeyCredentialSourceRepository over Eloquent.webauthn_credentials table (or a payload JSON column + indexed credential_id and user_handle).First use case to build: don't replace your password login. Add "register a passkey" as an opt-in second factor for already-authenticated users. It's the smallest surface (one page, two JSON endpoints, no account-recovery problem), it teaches you the ceremony dance, and it's useful on day one.
// app/Providers/WebauthnServiceProvider.php
$this->app->singleton(Server::class, function ($app) {
$rp = PublicKeyCredentialRpEntity::create(
config('webauthn.rp.name'),
config('webauthn.rp.id'), // "example.com", not "https://example.com"
);
return Server::create(
$rp,
$app->make(PublicKeyCredentialSourceRepository::class),
// attestation statement support manager — copy the wiring from the
// Symfony bundle's Resources/config in vendor/ rather than guessing
);
});
The two-call ceremony. Every WebAuthn flow is: server generates options → browser talks to the authenticator → browser posts the response back → server verifies against the same options you stored. The options object is the state; never let the client hand it back to you.
// POST /webauthn/register/options (auth required)
public function registerOptions(Request $request)
{
$user = PublicKeyCredentialUserEntity::create(
$request->user()->email, // display name
$request->user()->webauthn_handle, // raw bytes, <= 64, stable forever
$request->user()->name,
);
$options = $this->server->generatePublicKeyCredentialCreationOptions(
$user,
excludeCredentials: $this->repo->descriptorsFor($request->user()), // stop re-registering the same key
authenticatorSelectionCriteria: AuthenticatorSelectionCriteria::create(
userVerification: AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_REQUIRED,
residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_REQUIRED, // passkey
),
attestation: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
);
$request->session()->put(
'webauthn.register',
$this->serializer->serialize($options, 'json')
);
return response()->json($options); // library DTOs are JSON-serializable
}
// POST /webauthn/register/verify
public function registerVerify(Request $request)
{
$json = $request->session()->pull('webauthn.register'); // pull = one-shot
abort_unless($json, 419, 'Ceremony expired');
$options = $this->serializer->deserialize(
$json, PublicKeyCredentialCreationOptions::class, 'json'
);
$credential = $this->server->loadAndCheckAttestationResponse(
json_encode($request->input('credential')), // array → JSON string
$options,
);
$this->repo->saveCredentialSource(
$this->server->generatePublicKeyCredentialSource($credential)
);
return response()->json(['ok' => true]);
}
Persistence: serialize the source, index two columns. PublicKeyCredentialSource is a rich value object (trust path, AAGUID, public key, counter, transports). Don't shred it into 15 columns; store the serialized object and index only what you query by.
class WebauthnCredential extends Model
{
protected $casts = ['payload' => 'array'];
// credential_id (string, base64url, unique) | user_handle (binary/hex) | payload (json) | name | last_used_at
}
final class EloquentCredentialSourceRepository implements PublicKeyCredentialSourceRepository
{
public function __construct(private SerializerInterface $serializer) {}
public function findOneByCredentialId(string $id): ?PublicKeyCredentialSource
{
$row = WebauthnCredential::where('credential_id', Base64UrlSafe::encodeUnpadded($id))->first();
return $row
? $this->serializer->deserialize(json_encode($row->payload), PublicKeyCredentialSource::class, 'json')
: null;
}
public function findAllForUserEntity(PublicKeyCredentialUserEntity $user): array { /* by user_handle */ }
public function saveCredentialSource(PublicKeyCredentialSource $s): void
{
WebauthnCredential::updateOrCreate(
['credential_id' => Base64UrlSafe::encodeUnpadded($s->getPublicKeyCredentialId())],
['user_handle' => $s->getUserHandle(), 'payload' => $this->serializer->serialize($s, 'json')]
);
}
}
Authentication, with and without a username. Supply allowCredentials for the classic "username first, then tap key" flow; omit it for passkey/usernameless (the authenticator returns userHandle, which you use to look the user up).
public function loginOptions(Request $request)
{
$allowed = $request->filled('email')
? $this->repo->descriptorsFor(User::whereEmail($request->email)->firstOrFail())
: []; // empty ⇒ discoverable-credential / passkey flow
$options = $this->server->generatePublicKeyCredentialRequestOptions(
userVerification: PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_REQUIRED,
allowedCredentials: $allowed,
);
$request->session()->put('webauthn.login', $this->serializer->serialize($options, 'json'));
return response()->json($options);
}
public function loginVerify(Request $request)
{
$options = $this->serializer->deserialize(
$request->session()->pull('webauthn.login'), PublicKeyCredentialRequestOptions::class, 'json'
);
$source = $this->server->loadAndCheckAssertionResponse(
json_encode($request->input('credential')),
$options,
null, // user entity, when known
$request->getHost(), // rpId host used for origin/hash verification
);
// counter is already incremented on $source — persist it, or clone detection breaks
$this->repo->saveCredentialSource($source);
$user = User::where('webauthn_handle', $source->getUserHandle())->firstOrFail();
Auth::login($user);
$request->session()->regenerate();
return response()->json(['ok' => true]);
}
Frontend: @simplewebauthn/browser fits the library's JSON shape. The library emits/accepts base64url strings, so you rarely need a converter.
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
const opts = await (await fetch('/webauthn/register/options', { method: 'POST', headers: csrf() })).json();
const credential = await startRegistration({ optionsJSON: opts });
await fetch('/webauthn/register/verify', { method: 'POST', headers: csrf(), body: JSON.stringify({ credential }) });
Note startRegistration expects { optionsJSON } in current SimpleWebAuthn; older tutorials use the bare object.
Step-up / re-authentication. For sensitive actions, run the assertion ceremony again against allowCredentials limited to that user's credentials, and require userVerification: REQUIRED. It's cheap and it's the strongest pattern WebAuthn gives you.
Credential management UI. Users need to see and delete passkeys (lost device = dead key). Also store a user-supplied label and last_used_at on each verify; you'll want them when someone says "which one is my laptop?".
Integration tips.
throttle:10,1) — they're unauthenticated in the login flow and each one hits your DB.composer why symfony/serializer and composer why cbor/constant_time before shipping. 4.9.3 changed dependency constraints; the library code you reviewed is fine, but the packages underneath it may not be the same ones upstream validated.Webauthn\Server behind your own thin PasskeyService interface, and assert your ceremony state handling (options stored, options pulled once, source saved, session regenerated).Fork risk is the headline gotcha. No stars, no dependents, no release notes beyond auto-generated links. 4.9.3 is the first release since your review and it contains zero code — that's a good sign, but it also demonstrates the fork ships tags for housekeeping. Pin the exact SHA (not 4.9.3, not dev-main), add a scheduled CI job that diffs your pinned vendor/ copy against upstream 4.9.2, and be ready to swap to web-auth/webauthn-lib — the API is identical, so the swap is mostly composer.json + import paths. If the "personal changes" touch AuthenticatorAssertionResponseValidator, PublicKeyCredentialSource, or the attestation support classes, assume they affect security semantics and review them line by line.
Version shadowing (new in 4.9.3). The fork now publishes 4.9.3, a number that either doesn't exist upstream or means something else there. Consequences: composer audit advisories keyed to web-auth/webauthn-lib will never fire for your farsh4d/… package, composer outdated --direct won't tell you upstream got a security patch, and a future fork tag could "look newer" while being older code. Mitigation: track upstream's tags yourself (a weekly CI job on web-auth/webauthn-framework's releases is enough) and treat any upstream 4.9.x/4.10.x release as a prompt to re-diff.
RP ID ≠ origin ≠ URL. rp.id must be a registrable domain suffix of the page origin: example.com or app.example.com, never https://example.com and never with a port. localhost is valid over plain HTTP in dev — but 127.0.0.1 and localhost are distinct RP IDs, so pick one and never switch mid-testing. A mismatch here produces a clientDataJSON origin failure that looks like a library bug and isn't.
Store the options server-side, always, and expire them. The challenge is the only thing preventing replay. Session is fine for a single-node app; use the cache with a 2–5 minute TTL if you're load-balanced without sticky sessions. pull() (not get()) so the ceremony is one-shot.
userHandle is bytes, and it's forever. It must be ≤ 64 bytes and must never change for a given account. Don't use an auto-increment ID as a string casually (fine, but it leaks enumeration and is fragile if you ever migrate); use a UUID stored as a 16-byte binary or hex column. The userHandle returned by a discoverable credential is what maps the login back to your user — get it wrong and passkeys log into the wrong account.
Persist the counter after every assertion. The library increments the stored counter on the returned PublicKeyCredentialSource. Skip the save and you lose clone detection and start seeing flaky "counter too small" errors on legitimate logins.
Attestation is noise unless you have a policy. ATTESTATION_CONVEYANCE_PREFERENCE_NONE is right for consumer apps — most authenticators return self-attestation or none anyway, and verifying real attestation requires the FIDO Metadata Service. If you do enable MDS, cache the blob aggressively (it's a large network fetch) and make sure an MDS outage doesn't lock everyone out.
JSON shapes are the #1 debugging time sink. $request->input('credential') from a fetch body is an array; the library wants a JSON string (json_encode). Conversely, the options you return must be the library's serialized form, not (array) $options. If the browser throws TypeError: credential.response is undefined, you mangled one of these two.
Debugging ladder.
Webauthn\Exception\* classes embed the failing check (origin, challenge, rpIdHash, UV flag) plus OpenSSL detail.chrome://webauthn-internals shows the raw ceremonies the browser ran.webauthn.io on the same device to separate "my server is wrong" from "this authenticator is odd".clientDataJSON in staging (never the challenge in production logs).Symfony config won't transfer. Don't copy the bundle's YAML and expect it to work — you're reimplementing ~40 lines of container wiring. Read vendor/…/webauthn-symfony-bundle/Resources/config/ as a spec for which normalizers, support managers, and validators must be registered. Missing one produces cryptic denormalization failures at runtime, not boot time.
Serializer drift. 4.x moved to attribute-driven (de)normalizers. If you have an older Symfony Serializer, or you're on Laravel and using a slimmed-down setup, PublicKeyCredentialCreationOptions will silently deserialize into half-populated objects. 4.9.3's dependency-constraint change makes this more likely, not less — an unpinned transitive bump is exactly how you end up denormalizing into half-empty DTOs. Pin symfony/serializer to a compatible major and add a smoke test that round-trips a PublicKeyCredentialCreationOptions.
Extension points worth knowing. PublicKeyCredentialSourceRepository is the only interface you must implement; everything else (attestation supports, extension output checkers, metadata repositories) has a default that's fine to leave alone. 4.9.3 adds no new extension points. If you need custom behaviour — say, refusing authenticators without UV — do it in your own controller before calling Server, not by forking further.
CSRF and cookies. Sanctum SPA setups need the X-XSRF-TOKEN header on the fetch calls; a 419 on the verify endpoint after a perfect-looking ceremony is almost always this, and it wastes an hour because the error surfaces as "registration failed".
How can I help you explore Laravel packages today?