Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Face Auth Laravel Package

mpociot/laravel-face-auth

Laravel Face Auth adds facial-recognition login to Laravel apps. Users enroll their face via webcam, descriptors are stored, and authentication works alongside Laravel’s existing auth system using face-api.js.

View on GitHub
Deep Wiki
Context7

Getting Started

What it actually is. A thin integration layer, not a recognition engine. Four moving parts:

  1. A service provider that binds a config file and registers routes/controllers/views.
  2. A publishable config holding the Microsoft Face API endpoint + subscription key and a match threshold.
  3. A migration adding a reference-image column to users.
  4. A webcam capture view plus a controller action that posts the captured frame to the Face API, compares it to the enrolled reference, and then logs the user in through Laravel's normal auth flow.

If you are evaluating it, read the source in this order — it takes under an hour:

src/*ServiceProvider.php      → what is bound, what is registered
config/*.php                  → credentials, threshold, route/controller overrides
database/migrations/*         → the column you will need to reconcile
src/Http/Controllers/*        → enroll / verify endpoints, image handling
routes/*.php                 → route names and middleware
resources/views/*            → webcam capture JS contract (what is POSTed)

Prerequisites, and the first thing to verify. The package targets PHP 7 / Laravel 5.x and calls the Microsoft Face API. Confirm before you write any code that your Azure resource still has Face verification/identification access approved — that capability has been restricted and parts of the service retired. If you cannot get a key that works against the package's endpoint, stop here; the rest is a UX spike only.

Minimal setup (on a throwaway branch, never on real accounts):

composer require mpociot/laravel-face-auth   # expect constraint conflicts; see Gotchas
php artisan vendor:publish --provider="Mpociot\FaceAuth\FaceAuthServiceProvider"
php artisan migrate

Add the package's trait (or reference-image accessors) to App\User, then set credentials in .env — the config file will name the exact keys, typically something like:

FACE_API_KEY=...
FACE_API_ENDPOINT=https://<region>.api.cognitive.microsoft.com/face/v1.0
FACE_MATCH_THRESHOLD=0.6

Capture flow on the front end is a standard getUserMedia → <canvas> → toDataURL('image/jpeg') → POST. Note getUserMedia requires HTTPS or localhost.

First use case: step-up verification, not login replacement. Do not start by replacing your login form. Pick one low-stakes route (/settings, an admin "unlock" screen, a kiosk clock-in) and require a face check on top of an existing session. You get the whole enrollment → capture → verify → pass/fail loop exercised with a fraction of the blast radius, and you keep passwords as the fallback from day one. Only after that try the full passwordless flow the package ships, because that is the path that needs liveness, consent, and anti-replay — none of which exist here.

Implementation Patterns

Enrollment (authenticated, one-off). Treat it as a normal form submission with an image payload.

// routes/web.php — behind auth, rate limited, feature flagged
Route::middleware(['auth', 'throttle:10,1', 'can:enroll-face'])
    ->group(function () {
        Route::get('/face/enroll',  [FaceController::class, 'showEnroll']);
        Route::post('/face/enroll', [FaceController::class, 'storeEnroll']);
    });
public function storeEnroll(Request $request)
{
    $data = $request->validate([
        'image' => ['required', 'string', 'max:6000000'], // base64 payload
    ]);

    $jpeg = $this->stripDataUrl($data['image']); // "data:image/jpeg;base64," prefix

    $result = $this->matcher->enroll($request->user(), $jpeg); // detect + persist

    $request->user()->forceFill([
        'face_reference' => encrypt($jpeg),   // never store plaintext
        'face_enrolled_at' => now(),
        'face_provider_id' => $result->personId,
    ])->save();

    return back()->with('status', 'Face enrolled.');
}

Two patterns worth adopting immediately, because the package does not:

  • Wrap the vendor behind your own contract. This is the single highest-value change and turns a dead dependency into a swappable one.
interface FaceMatcher
{
    public function enroll(Authenticatable $user, string $jpeg): EnrollResult;
    public function verify(Authenticatable $user, string $jpeg): MatchResult; // -> matched, confidence
}

// FaceAuthServiceProvider (yours, not the package's)
$this->app->bind(FaceMatcher::class, fn ($app) => match (config('face.driver')) {
    'azure'     => new AzureFaceMatcher($app['http'], config('face.azure')),
    'fake'      => new FakeFaceMatcher(),
    default     => throw new InvalidArgumentException('Unknown face driver'),
});
  • Store the reference image on a private disk, encrypted, with a retention job. Column type longText (or mediumBlob if you move to a dedicated table) — not a string(255).

Verification / login flow. The realistic variant is two-factor-ish: identify the user by email/username, then verify the captured frame.

public function verify(Request $request)
{
    $data = $request->validate([
        'email' => ['required', 'email'],
        'image' => ['required', 'string'],
        'password_fallback' => ['nullable', 'string'], // always present in the UI
    ]);

    $user = User::where('email', $data['email'])->first();

    // Always run the same code path shape, whatever the outcome, to limit timing/user enumeration.
    $result = $user && $user->face_reference
        ? $this->matcher->verify($user, $this->stripDataUrl($data['image']))
        : MatchResult::failed();

    $this->audit->record($user, $result, $request); // attempt log, never the image

    abort_unless($result->matched && $result->confidence >= config('face.threshold'),
        422, 'Face verification failed.');

    Auth::login($user, remember: true);
    $request->session()->regenerate();

    return redirect()->intended('/dashboard');
}

Step-up middleware (the safer integration). Rather than a new guard, gate a route on "verified recently":

class RequireRecentFaceCheck
{
    public function handle($request, Closure $next, int $minutes = 10)
    {
        $at = $request->session()->get('face.verified_at');

        if (! $at || $at->lt(now()->subMinutes($minutes))) {
            return redirect()->route('face.challenge', ['intended' => $request->fullUrl()]);
        }

        return $next($request);
    }
}

This is the pattern I would ship from a spike into an internal tool: it composes with auth, ignores guards entirely, and the fallback (password re-entry) is a redirect away.

Guard driver, only if you must. If you genuinely want Auth::guard('face')->attempt(...), implement Illuminate\Contracts\Auth\Guard (or extend SessionGuard) and register it in config/auth.php. The package's own driver is written against Laravel 5-era guard APIs; expect to rewrite user(), check(), validate(), and the login/logout events yourself.

Threshold and score handling. The verify API returns a confidence; the config threshold is the entire security policy. Lift it into your own config and log every real score during the spike:

// config/face.php
return [
    'driver'    => env('FACE_DRIVER', 'fake'),
    'threshold' => (float) env('FACE_THRESHOLD', 0.6), // 0.5–0.7 band, tune with data
    'azure'     => [
        'key'      => env('FACE_API_KEY'),
        'endpoint' => env('FACE_API_ENDPOINT'),
        'group'    => env('FACE_API_GROUP'),
    ],
];

Log (user_id, matched, confidence, ip, ua, duration) to a dedicated table. That table is the deliverable of the spike — it tells you false-accept/false-reject rates, which is the only thing that justifies a real vendor decision.

Testing: bind a fake, never hit the network.

// tests/TestCase.php / a test service provider
$this->app->bind(FaceMatcher::class, fn () => new FakeFaceMatcher(
    matched: true, confidence: 0.91
));

Add a small artisan command (face:fixtures) that seeds deterministic JPEGs from tests/Fixtures so your UI tests exercise the enrollment screen without a webcam. Upstream has no tests, so you own this.

Feature-flag every entry point. Wrap routes and UI in config('features.face_auth') or a Pennant/Feature::active() check. The moment the vendor API is unavailable, you want the webcam block to disappear rather than error.

Gotchas and Tips

The vendor is the hard blocker, not the code. Microsoft restricted Face identification/verification to approved subscriptions and retired parts of the service. FaceMatcher above exists specifically so you can swap in Azure AI Vision Face, AWS Rekognition, or self-hosted InsightFace without touching controllers. Assume the package's default client will not survive your project.

Installation will fight you. 2017-era constraints mean no auto-discovery (register the provider manually in config/app.php), Guzzle/illuminate/* conflicts on anything past Laravel 5.x/6.x, and no PHP 8 support. Resolve with --ignore-platform-reqs only to read the code; for a real spike, composer require mpociot/laravel-face-auth:dev-master --no-update plus a fork in repositories with patched constraints. Never mix a constraints override into a shared composer.json.

Do not run the published migration as-is. It will assume your users table matches 2017 conventions. Write your own migration; reconcile the column and index names with your schema, and consider a separate face_credentials table (user_id, encrypted reference, provider person/face id, enrolled_at, revoked_at) so biometric data can be deleted independently of the user row.

Base64 handling bites in three places.

  • The data URL prefix (data:image/jpeg;base64,) must be stripped before sending, or the API returns "no face detected" with a 200.
  • PHP's post_max_size / upload_max_filesize reject the POST with an empty $_POST — symptoms look like a CSRF failure. Check storage/logs and php -i rather than the app.
  • Very small or very large frames fail detection (the API expects roughly 1KB–4MB, JPEG/PNG). Downscale the canvas client-side to ~640px wide; it also cuts latency.

Client-side capture gotchas. getUserMedia needs HTTPS or localhost. Front cameras deliver mirrored images — irrelevant to matching, confusing in the UI. Include a retake button; a single bad frame produces a false reject the user reads as "the system is broken."

CSRF and sessions. The capture POST is a normal web route — include the token in the fetch headers or you get 419s that look like auth failures. If you drive verification from a SPA on a different origin, this package's routes are web-middleware/session-based, so you will be reworking them.

Leaking biometric data through your API. Add the reference column to $hidden on the model, and never return the user object straight from the enroll endpoint. toArray() on a naive model will ship a base64 face image to the browser/logs. Also check Log::info($request->all()) calls in your own code.

Threshold semantics are the whole risk model. Lowering confidence = fewer lockouts, more false accepts. There is no liveness detection, so a printed photo or a phone screen showing the enrolled image passes. Mitigations available to you cheaply: rate-limit verification (throttle:5,1), require a session/password factor alongside the face, expire "verified" state after minutes, and audit-log every attempt. Do not present face-only login as a security control.

Route collisions. Auth::routes() and the package's routes both touch /login. Either override the package's route prefix/controller in config, or register the face routes under /face/* and link to them from your login view. Check php artisan route:list after install.

Config caching. php artisan config:cache after changing the published config; env() calls inside the package config are fine only while reading config, not at runtime.

Guard/identity mismatches. If you use multiple guards or a non-User authenticatable, the package's controller likely resolves Auth::user() from the default guard. Pass the guard explicitly or you will verify the wrong model. Same for Auth::login() inside the controller — it defaults to the session guard.

Debugging checklist, in order: does the raw API call succeed in Postman with the same key/endpoint and a real JPEG? → is the base64 prefix stripped? → is the POST arriving at all (request size limits)? → does detection return a faceId or error: no face? → is the threshold comparison on a normalized float? → did Auth::login() fire (check the Login event)? Nine times out of ten the failure is the first three.

Extension points worth using: the publishable config (route prefix, controller, threshold, credentials), the published views (replace the webcam JS with your own component or upload fallback), and the migration (schema reference only). Everything else — guard, matcher, provider — plan to own.

Ownership reality. Zero dependents, last released March 2017, no upstream triage. Fork into your namespace, assign an owner, and treat the vendor client as code you maintain. For a two-day internal spike, fine. For anything with an SLA, biometric data, or real users, this is a reference implementation to read — the pattern is worth stealing, the dependency is not.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle
adachsoft/api-version-bundle
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
cgv/localized-resource