spomky-labs/u2f-php
spomky-labs/u2f-php is a PHP library for implementing U2F (FIDO Universal 2nd Factor) authentication. It helps register and verify hardware security keys on the server, adding strong two-factor login protection to PHP applications and integrating with the WebAuthn ecosystem.
This package is archived — treat it as a legacy dependency for existing codebases only. For new work, use web-auth/webauthn-lib (the successor from the same maintainer) which covers U2F/FIDO2/WebAuthn in one library.
If you're inheriting a project that already uses it:
composer require spomky-labs/u2f-php
The package ships no Laravel service provider. You wire three things yourself:
https://app.example.com. Put it in config/u2f.php and env('U2F_APP_ID').U2f\U2FServer so controllers stay testable.First use case to implement: registering a security key on an already-authenticated user. That single flow exercises every part of the package (challenge generation, JSON round-trip to the browser, response verification, persistence) and is the prerequisite for login.
// config/u2f.php
return ['app_id' => env('U2F_APP_ID')]; // must be the full origin, https in prod
// app/Services/U2fService.php
namespace App\Services;
use U2f\U2FServer;
class U2fService
{
public function __construct(private string $appId) {}
public function registrationRequest(array $registrations): object
{
return U2FServer::makeRegistrationRequest($this->appId, $registrations);
}
}
// GET /u2f/register — hand the browser its challenge
public function registerChallenge(U2fService $u2f)
{
$existing = auth()->user()->u2fKeys->map(fn ($k) => $k->toRegistration())->all();
$request = $u2f->registrationRequest($existing);
session(['u2f.register' => $request]); // the whole object, not the JSON
return response()->json($request);
}
// POST /u2f/register — verify and persist
public function registerStore(Request $request, U2fService $u2f)
{
$challenge = session()->pull('u2f.register'); // pull: one-shot, prevents replay
abort_unless($challenge, 419);
$response = U2FServer::checkRegistration($request->all(), $challenge);
$reg = $response->getRegistration();
auth()->user()->u2fKeys()->create([
'key_handle' => $reg->getKeyHandle(),
'public_key' => $reg->getPublicKey(),
'certificate' => $reg->getCertificate(),
'counter' => $reg->getCounter(),
'label' => $request->input('label'),
]);
return response()->json(['ok' => true]);
}
Mirror the same two-step shape. makeSignRequest returns an array of SignRequest objects (one per registered key), which is exactly what u2f-api.js expects:
$signRequests = U2FServer::makeSignRequest(config('u2f.app_id'), $registrations);
session(['u2f.sign' => $signRequests]);
return response()->json(['signRequests' => $signRequests]);
On the callback, checkSign($post, $signRequests) locates the matching request by key handle and verifies the signature. Always update the stored counter from the returned registration — a counter that fails to increase on the next login is your only real signal that a key has been cloned.
Keep the challenge object in the session (or a short-lived u2f_challenges table for stateless/API clients) with a TTL of ~5 minutes. Never round-trip it through the client as anything other than the object the library generated — clients must not be able to mutate the challenge or app ID.
You need u2f-api.js (the Google reference polyfill) in the browser; this package is server-side only. Typical Blade + JS:
u2f.register(appId, registerRequests, [], (data) => {
if (data.errorCode) return showError(data.errorCode);
axios.post('/u2f/register', data);
});
Store the registrationRequest JSON in a data attribute rather than inlining it into a <script>, so CSP nonces stay simple.
makeSignRequest is cheap but eager-loading avoids N+1 when listing keys.auth for registration and behind a rate-limited guest route for login (throttle:10,1 — hardware key guessing is slow but not free).toRegistration() cast helper keeps the domain layer free of U2f\ imports except in one place.It is archived and deprecated. U2F is a dead protocol — most browsers now route U2F keys through WebAuthn rather than exposing u2f-api.js. Budget a migration to web-auth/webauthn-lib; the data model (key handle, public key, counter) mostly carries over, but you'll want to store credential IDs and transports too.
App ID mismatch is the #1 failure. The configured app_id must be a prefix of the real page origin, over HTTPS, and must not change between registration and authentication. Moving from example.com to app.example.com invalidates every registered key. Use a separate App ID per environment and expect dev keys registered on localhost to be useless in staging.
HTTPS is mandatory outside localhost. There is no bypass flag. Chrome will silently refuse to talk to the key if the origin is http.
Challenges are one-shot. Always session()->pull(), never session()->get(). If you leave the challenge in the session, a replayed response verifies twice.
Errors are exception-based and typed. checkRegistration/checkSign throw on bad signatures, unknown key handles, and counter regressions. Catch specifically and map to distinct UI messages — "unknown key" (user registered elsewhere) reads very differently to "counter error" (treat as compromised; revoke the key). Don't swallow these into a generic 422.
Counters can be zero. Some older/cheap keys report 0 on every assertion and never increment. Treat new < old as an error but new === old === 0 as acceptable, or you'll lock out a fraction of users.
The library touches PSR-7/HTTP foundation types. Older versions returned objects tied to Symfony HTTP classes. If you're passing raw Laravel request arrays in, unwrap with $request->all() and check the version's expected shape — a ParameterBag vs array mismatch produces confusing "missing parameter" errors.
Testing. No virtual authenticator ships with the package. For CI, either stub U2FServer behind your service class and assert the flow (challenge stored, response persisted, counter updated), or drive a real Playwright/Chrome virtual authenticator against a seeded credential. Unit-testing the crypto path itself is not worth the effort.
Extension points are thin. There's no repository interface to bind; persistence is entirely yours. If you want a pluggable key store, define your own U2fKeyRepository contract and inject it into your service wrapper — don't try to hook into the library internals.
Debugging tip: when a registration fails with a generic error, dump the clientData object returned by the browser and confirm its origin and challenge before blaming the library. Nine times out of ten the challenge in the session and the challenge in clientData differ because the user opened a second tab.
How can I help you explore Laravel packages today?