Usage
How it works
The private key never leaves the device. Your API stores only the public key and proves the device holds the private key by asking it to sign a one-time challenge.
Preparing the model
use Laranex\LaravelBiometricAuth\Traits\HasBiometrics;
class User extends Authenticatable
{
use HasBiometrics;
}Biometrics are stored with the model's morph class (getMorphClass()), so Relation::morphMap() aliases are respected.
Registering a biometric
Store the device's base64-encoded public key (PEM or DER). Invalid keys throw InvalidPublicKeyException.
$biometric = $user->createBiometric($publicKeyBase64);
$biometric->id; // UUID the device keepsEvery biometric of a model, revoked ones included, is available through the biometrics() morph-many relation. The active() scope keeps only biometrics that are not revoked:
$user->biometrics()->active()->get();The public_key attribute is hidden when a Biometric is serialized.
Issuing a challenge
use Laranex\LaravelBiometricAuth\Facades\LaravelBiometricAuth;
$biometric = LaravelBiometricAuth::getBiometric($biometricId);
$biometric->challenge; // the device signs thisThe challenge is a random 64-character hex string. It is kept until it is verified or expires, so calling getBiometric() twice before the device answers returns the same challenge. A challenge expires challenge.ttl seconds (5 minutes by default) after it was issued; the next getBiometric() call then issues a fresh one.
Biometric ids are UUIDs: any other id throws BiometricNotFoundException without querying the database.
Verifying the signature
The device signs the challenge with its private key and sends the signature in standard base64 (a signature that is not valid base64 counts as a failed attempt):
use Laranex\LaravelBiometricAuth\Models\Biometric;
$verified = LaravelBiometricAuth::verifyBiometric(
$biometricId,
$signatureBase64,
);
if ($verified) {
// the model that registered the biometric
$user = Biometric::find($biometricId)->instance;
}Challenges are single-use: a successful verification clears the challenge, so a captured signature cannot be replayed. The challenge is consumed atomically, so when the same signature arrives twice at once only one request is verified. The next getBiometric() call issues a fresh one. An expired challenge is cleared and verifyBiometric() throws BiometricChallengeNotFoundException. A failed verification keeps the challenge so the device can retry, up to challenge.max_attempts failed attempts (5 by default, see Configuration); after that the challenge is cleared, verifyBiometric() throws BiometricChallengeNotFoundException and the client must call getBiometric() for a new one.
To resolve the service without the facade, use the container: app(\Laranex\LaravelBiometricAuth\LaravelBiometricAuth::class).
Revoking
Revoke one of the model's active biometrics so it can no longer be challenged or verified:
$user->revokeBiometric($biometricId);Exceptions
All exceptions live in Laranex\LaravelBiometricAuth\Exceptions.
| Exception | HTTP status | Thrown when |
|---|---|---|
BiometricNotFoundException | 404 | getBiometric() / verifyBiometric() get an unknown, revoked or non-UUID biometric id, or revokeBiometric() gets a biometric that does not exist, is already revoked or belongs to another model |
BiometricChallengeNotFoundException | 422 | verifyBiometric() is called with no pending challenge: none was issued, it was consumed by a successful verification, it was cleared after too many failed attempts, or it expired |
InvalidPublicKeyException | 422 | createBiometric() or verifyBiometric() cannot load the public key |
They extend Laranex\LaravelBiometricAuth\Exceptions\BiometricException. The status is also the exception code (getCode()) and is returned by getStatusCode(); passing a custom 4xx/5xx code to the constructor overrides it.
The exceptions are renderable: when the request expects JSON (an API call with Accept: application/json), an uncaught exception becomes a JSON error with that status:
{ "message": "Biometric not found" }Other requests fall through to your application's exception handler. Catch the exceptions, or register your own renderer with $exceptions->render() (Laravel 11+) or $this->renderable() in the exception handler (Laravel 10), to change the response.