yubico/u2flib-server
Server-side PHP library for U2F registration and authentication. It lets applications issue challenges, register compatible hardware security keys, and verify signed responses, handling protocol parsing and validation. U2F is legacy; use WebAuthn for new projects.
Read this first: yubico/u2flib-server is archived and marked OBSOLETE, and it implements U2F — a protocol that browsers no longer expose. Chrome and Firefox removed the u2f-api.js JS API; current keys are FIDO2/WebAuthn. There are zero dependents. So "day-to-day leverage" here means exactly one thing: maintaining an existing U2F flow on a legacy app, or building a server-side verifier behind a client you control. For new auth work, stop and use laragear/webauthn or web-auth/webauthn-lib instead — old U2F keys still register fine as WebAuthn fido-u2f attestation credentials.
If you're proceeding anyway:
composer require yubico/u2flib-server
php -m | grep -i gmp # optional; enables faster bignum math
Sanity-check it loads on your PHP version — the library predates PHP 8 and can emit deprecation notices (dynamic properties) on 8.1+.
Where to look first: vendor/yubico/u2flib-server/src/u2flib_server/U2F.php and Error.php. The whole library is a few hundred lines and two classes. Read U2F.php end to end — it has four methods you'll actually call:
getRegisterData($registrations) → [$registerRequest, $signRequests]doRegister($registerRequest, $clientResponse) → RegistrationgetAuthenticateData($registrations) → sign request(s)doAuthenticate($requests, $registrations, $clientResponse) → updated RegistrationFirst use case — bind it and register a key. There is no service provider, so wire it yourself:
// app/Providers/AppServiceProvider.php
$this->app->singleton(\u2flib_server\U2F::class, fn () => new \u2flib_server\U2F(
config('u2f.app_id') // e.g. https://app.example.com — origin only, no path
));
// config/u2f.php
return ['app_id' => env('U2F_APP_ID', config('app.url'))];
Migration — store the registration verbatim:
Schema::create('u2f_registrations', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
$t->string('key_handle', 255)->unique();
$t->text('public_key');
$t->text('certificate');
$t->unsignedBigInteger('counter')->default(0);
$t->string('name')->nullable();
$t->timestamps();
});
Controller:
public function begin(Request $request, U2F $u2f)
{
[$registerRequest, $signRequests] = $u2f->getRegisterData(
$request->user()->u2fRegistrations()->get()->map->toRegistration()->all()
);
Cache::put("u2f:reg:{$request->user()->id}", $registerRequest, now()->addMinutes(5));
return response()->json([
'registerRequests' => [$registerRequest],
'signRequests' => $signRequests,
]);
}
public function finish(Request $request, U2F $u2f)
{
$request->validate(['response' => ['required', 'array']]);
$regRequest = Cache::pull("u2f:reg:{$request->user()->id}");
abort_unless($regRequest, 419, 'Challenge expired');
$reg = $u2f->doRegister($regRequest, (object) $request->input('response'));
$request->user()->u2fRegistrations()->create([
'key_handle' => $reg->keyHandle,
'public_key' => $reg->publicKey,
'certificate' => $reg->certificate,
'counter' => $reg->counter,
'name' => $request->input('name'),
]);
return response()->json(['ok' => true]);
}
Keep the challenge in the cache, not the DB. There's no server-side nonce store in the library — getRegisterData/getAuthenticateData generate a random challenge and you must hand the same object back to doRegister/doAuthenticate. Session or Cache::put(..., 5 minutes) both work; key it per-user and per-purpose (u2f:reg:{id} vs u2f:auth:{id}) so two open tabs don't clobber each other.
Round-trip registrations as objects, not arrays. The library reads $registration->keyHandle, ->publicKey, ->counter, etc. A model accessor that returns the decoded struct:
public function toRegistration(): \u2flib_server\Registration
{
return (object) [
'keyHandle' => $this->key_handle,
'publicKey' => $this->public_key,
'certificate' => $this->certificate,
'counter' => (int) $this->counter,
];
}
Never json_decode($json, true) into arrays for this — doAuthenticate will blow up.
Authentication flow mirrors registration, plus a counter write-back:
$requests = $u2f->getAuthenticateData($user->u2fRegistrations->get()->map->toRegistration()->all());
Cache::put("u2f:auth:{$user->id}", $requests, now()->addMinutes(5));
// client posts { signRequests, signatures }
$updated = $u2f->doAuthenticate(
Cache::pull("u2f:auth:{$user->id}"),
$user->u2fRegistrations->get()->map->toRegistration()->all(),
(object) $request->input('response')
);
$user->u2fRegistrations()->where('key_handle', $updated->keyHandle)
->update(['counter' => $updated->counter]);
Gate it as a second factor, not a login. The natural Laravel pattern is a EnsureU2fVerified middleware plus a password-confirmation step (password.confirm) before /u2f/register/*, so a stolen session can't enrol an attacker's key.
CSRF. The client posts JSON via fetch/axios; either send X-CSRF-TOKEN (axios does this from the meta tag automatically) or add u2f/* to VerifyCsrfToken::$except. Prefer the header — you want the CSRF check.
Testing. Don't try to forge client responses. Bind a fake in the container:
$this->mock(\u2flib_server\U2F::class, function ($m) {
$m->shouldReceive('getRegisterData')->andReturn([['challenge' => 'x'], []]);
$m->shouldReceive('doRegister')->andReturn((object) [
'keyHandle' => 'kh', 'publicKey' => 'pk', 'certificate' => 'c', 'counter' => 0,
]);
});
Then assert the row lands in u2f_registrations and the challenge was pulled from cache. Test the counter-regression path separately by feeding doAuthenticate a lower counter through a stub.
Behind a load balancer / proxy: set AppID from a config value, never from $request->getSchemeAndHttpHost(). Host-header spoofing would let an attacker mint a challenge for their own origin. Also make sure TrustProxies and URL::forceScheme('https') are configured — U2F only works over HTTPS (localhost excepted).
https://example.com ≠ https://example.com/ ≠ https://www.example.com. A mismatch shows up as a signature/BAD_REQUEST error on the client with no useful server-side message. Put it in .env per environment and assert it on boot.u2flib_server\Error carries getCode() with U2F_* constants. Catch it and translate to human text before it hits your 500 handler — otherwise users see an ugly stack trace for "you tapped the wrong key":
catch (\u2flib_server\Error $e) {
throw ValidationException::withMessages(['u2f' => 'Key rejected. Try again.']);
}
doAuthenticate throws when the new counter isn't greater than the stored one (clone detection). Some cheap tokens always report 0, which the library tolerates, but if you restore a DB backup you'll lock users out. Provide an admin "reset counter" path.doRegister gets a stale request. Always Cache::pull (delete-on-read) and return a 419 so the JS can restart the ceremony cleanly.ext-gmp = slow pure-PHP bignum. Fine for a login endpoint, painful if you script a bulk verify. The second constructor arg (new U2F($appId, $useGmp = true)) is the toggle.json_encode of a Registration includes certificate, which can be a binary blob. Store base64 or raw and decode on read — don't let a text column choke on invalid UTF-8.u2f-api.js (Google's, unmaintained) and browsers that still expose window.u2f. Current Chrome and Firefox do not. Test this before you write any server code, or you'll ship a login flow nobody can complete.app/Support/U2f — it's ~300 lines and you'll have more control than patching via composer.u2f_registrations readable so you can offer "your YubiKey still works" messaging rather than a hard cutover.How can I help you explore Laravel packages today?