Stripe Identity Verification in Laravel — KYC Without a Compliance Vendor

Wire Stripe Identity verification into Laravel with verification sessions and webhooks. KYC for $1.50 per check — no separate compliance vendor needed.

Steven Richardson
Steven Richardson
· 11 min read

I shipped KYC for a regulated marketplace last quarter. The product team came in assuming we'd procure a dedicated identity vendor — Onfido, Persona, Veriff, pick one — and budgeted ~$8K in onboarding fees plus per-check pricing. Then I noticed Stripe Identity was already enabled on our account. One afternoon later, sellers were being verified through the same Stripe webhook pipeline that processes payouts. Stripe Identity verification in Laravel is essentially the Checkout session pattern with a different payload. Here's the full wire-up.

Enable Stripe Identity in your Stripe dashboard#

Identity is opt-in. Sign into the Stripe dashboard, go to Identity → Get started, and complete the activation form. You'll need to declare the use case (marketplace KYC, age verification, regulated content, fraud prevention) and the regions you operate in. Activation is usually instant for established accounts; brand new accounts may need a brief review.

Pricing is $1.50 per completed verification in the US, billed at the end of the month against your normal Stripe balance. That single fee covers both the ID document check and the selfie liveness step.

Once active, generate a restricted API key under Developers → API keys. The "Restricted" pattern is the same one I recommend in verifying Stripe webhook signatures in Laravel — never use the unrestricted secret key for non-billing flows.

Install the Stripe PHP SDK#

Pull in the official PHP library. If Cashier is already installed, the SDK ships transitively, but I prefer to require it explicitly so the version is pinned where I can see it:

composer require stripe/stripe-php

At the time of writing, the latest stable is v20.0.0 with the pinned API version 2026-04-22.dahlia. If you already have an older stripe-php from a Cashier install, run composer show stripe/stripe-php to check — anything in the v15+ range exposes the Identity namespace.

Configure the keys in config/services.php:

'stripe' => [
    'secret' => env('STRIPE_SECRET'),
    'webhook_secret' => env('STRIPE_IDENTITY_WEBHOOK_SECRET'),
],

I use a dedicated webhook secret for Identity even when sharing the same endpoint pattern as Cashier — Stripe lets you register multiple webhook endpoints, and a separate secret per concern means a compromised secret has a narrower blast radius.

Create a route that opens a verification session#

This is the server-side handler that mints a session and hands the user a URL to redirect to. Generate the controller:

php artisan make:controller IdentityVerificationController --no-interaction

Then implement the start action:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Stripe\StripeClient;

class IdentityVerificationController extends Controller
{
    public function __construct(private StripeClient $stripe) {}

    public function start(Request $request): RedirectResponse
    {
        $user = $request->user();

        $session = $this->stripe->identity->verificationSessions->create([
            'type' => 'document',
            'provided_details' => [
                'email' => $user->email,
            ],
            'options' => [
                'document' => [
                    'require_matching_selfie' => true,
                    'require_live_capture' => true,
                ],
            ],
            'metadata' => [
                'user_id' => (string) $user->id,
            ],
            'return_url' => route('identity.return'),
        ]);

        $user->forceFill([
            'stripe_identity_session_id' => $session->id,
        ])->save();

        return redirect()->away($session->url);
    }
}

A few things to notice. The type is document — that's the photo ID + selfie liveness flow you almost always want for KYC. metadata.user_id is your handle for matching the session back to a user when the webhook arrives — Stripe will echo it back verbatim. The hosted session->url is a stripe.com URL that expires in 48 hours and is one-shot, so don't cache it.

Bind the client in a service provider so the controller stays clean:

// app/Providers/AppServiceProvider.php

use Stripe\StripeClient;

public function register(): void
{
    $this->app->singleton(StripeClient::class, fn () =>
        new StripeClient(config('services.stripe.secret'))
    );
}

Render the hosted Stripe Identity flow on the frontend#

The cheapest, most maintainable option is to redirect to the hosted page — Stripe handles the entire capture experience on their domain and bounces the user back to your return_url. A simple Blade button is all you need:

<form method="POST" action="{{ route('identity.start') }}">
    @csrf
    <button type="submit" class="btn btn-primary">
        Verify your identity
    </button>
</form>

If you'd rather embed the flow inside your app (no full-page redirect), use the client_secret with Stripe.js instead. The trade-off: you ship more frontend code and you're responsible for the modal lifecycle. For the marketplace work I shipped, the hosted redirect won easily — Stripe iterates on the UI faster than I want to maintain a custom one. The same logic applies as in Stripe Connect Express onboarding for Laravel: take the hosted flow unless you have a strong brand reason not to.

Add the return route to handle the user bouncing back:

// routes/web.php
Route::middleware('auth')->group(function () {
    Route::post('/identity/start', [IdentityVerificationController::class, 'start'])
        ->name('identity.start');

    Route::get('/identity/return', [IdentityVerificationController::class, 'returned'])
        ->name('identity.return');
});

The returned handler should be optimistic — the user has been bounced back, but the authoritative result will arrive via webhook. Show a "We're verifying your details" pending state and let the webhook update the database.

Listen for identity.verification_session events#

Stripe emits an event every time a session changes state. The three you must care about are:

  • identity.verification_session.verified — the document and selfie passed
  • identity.verification_session.requires_input — the document failed (blurry, wrong type, mismatched selfie); the user needs to retry
  • identity.verification_session.canceled — you or the user canceled the session

There's also created and processing, but I rarely act on those server-side — they're useful for observability dashboards, not user state.

Register the endpoint in the Stripe dashboard under Developers → Webhooks → Add endpoint. Point it at https://your-app.test/webhooks/stripe-identity and subscribe only to the three Identity events. Grab the signing secret and stash it in .env as STRIPE_IDENTITY_WEBHOOK_SECRET.

For local testing, use ngrok or expose share to tunnel a public URL to your Herd site, or run stripe listen --forward-to https://your-app.test/webhooks/stripe-identity from the Stripe CLI.

Verify the webhook signature and update your user model#

This is the load-bearing step. Stripe signs every webhook with a timestamped HMAC; if you skip verification, anyone can POST a forged "verified" payload at your endpoint and fraudulently graduate users. The pattern is identical to the one I covered in verifying Stripe webhook signatures in Laravel and the lifecycle handling in Stripe subscription lifecycle webhooks in Laravel.

First, exclude the route from CSRF in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'webhooks/stripe-identity',
    ]);
})

Then the controller:

<?php

namespace App\Http\Controllers\Webhooks;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
use Symfony\Component\HttpFoundation\Response;

class StripeIdentityWebhookController extends Controller
{
    public function __invoke(Request $request): Response
    {
        try {
            $event = Webhook::constructEvent(
                $request->getContent(),
                $request->header('Stripe-Signature'),
                config('services.stripe.webhook_secret'),
            );
        } catch (SignatureVerificationException $e) {
            Log::warning('Stripe Identity webhook signature failed', [
                'error' => $e->getMessage(),
            ]);

            return response('Invalid signature', 400);
        }

        $session = $event->data->object;
        $userId = $session->metadata->user_id ?? null;

        $user = $userId ? User::find($userId) : null;

        if (! $user) {
            return response('', 200);
        }

        match ($event->type) {
            'identity.verification_session.verified' => $user->forceFill([
                'identity_verified_at' => now(),
                'identity_failure_reason' => null,
            ])->save(),

            'identity.verification_session.requires_input' => $user->forceFill([
                'identity_verified_at' => null,
                'identity_failure_reason' => $session->last_error->code ?? 'unknown',
            ])->save(),

            'identity.verification_session.canceled' => $user->forceFill([
                'identity_verified_at' => null,
                'identity_failure_reason' => 'canceled',
            ])->save(),

            default => null,
        };

        return response('', 200);
    }
}

Note two things. First, $request->getContent() returns the raw body — Stripe's signature is computed against the unmodified payload, so any middleware that re-encodes the body breaks verification. Second, I never store verified_outputs (name, DOB, address) directly on the user. The migration is intentionally lean:

// database/migrations/2026_05_11_120000_add_stripe_identity_to_users.php

Schema::table('users', function (Blueprint $table) {
    $table->string('stripe_identity_session_id')->nullable()->after('email');
    $table->timestamp('identity_verified_at')->nullable();
    $table->string('identity_failure_reason')->nullable();
});

If you need the verified name or DOB for a specific business decision, retrieve the session on demand with verified_outputs expanded:

$session = $this->stripe->identity->verificationSessions->retrieve(
    $user->stripe_identity_session_id,
    ['expand' => ['verified_outputs']],
);

$firstName = $session->verified_outputs->first_name;

That keeps the raw PII inside Stripe's vault — your database holds only a pointer.

Show a polished success and retry experience#

A clean UX has three states: pending (user just came back, webhook hasn't fired yet), verified (session passed), and requires-input (session failed, show retry).

@if ($user->identity_verified_at)
    <flux:badge color="green">Identity verified</flux:badge>
@elseif ($user->identity_failure_reason)
    <flux:callout variant="warning">
        We couldn't verify your ID — {{ $user->identity_failure_reason }}.
        <form method="POST" action="{{ route('identity.start') }}" class="mt-3">
            @csrf
            <flux:button type="submit">Try again</flux:button>
        </form>
    </flux:callout>
@else
    <p>We're verifying your details. This usually takes under a minute.</p>
@endif

When Try again posts to identity.start, you create a new session — verification sessions are one-shot by design, so don't try to resume the old one. The user gets a fresh hosted URL and the new session id replaces the old one on the user row.

Gotchas and edge cases#

A few real ones from production.

Test mode coverage is narrow. Stripe ships test fixtures (VS_test_123 style ids) and a "successful" outcome in test mode, but the live document capture flow does not run. Plan a single live-mode test pass with a real ID before going to production.

Webhook ordering isn't guaranteed. I've seen processing arrive after verified under heavy load. Always use match on event.type and treat the database write as idempotent — re-applying the same event must produce the same result.

Some countries are unsupported. Stripe Identity supports document checks in ~60 countries; the list shifts. If you operate globally, gate the start route by the user's country and fail loudly when unsupported, otherwise the hosted page will surface a confusing "unable to verify" error.

Don't put a verification session into a job retry loop. If requires_input fires, the user has to retry — your code can't. Surface the failure in the UI and let them re-initiate.

Webhook bodies are huge in Identity events. The full session object is a few KB, which is fine, but if you log payloads make sure your logger isn't echoing PII to a third-party log service. Strip verified_outputs before logging.

Wrapping up#

Stripe Identity earns its place in a Laravel KYC stack because it gives you most of what a dedicated vendor offers — hosted capture, document + selfie liveness, signed verified outputs — for $1.50 per check, behind the same webhook signature pattern you already wrote. The whole integration is one controller, one webhook handler, three new columns.

If you're building this into a marketplace, the next step is wiring the verified flag into your payouts flow — see Stripe Connect Express onboarding for Laravel for that side of the integration. And if you haven't already, do a pass on the webhook handler test coverage using the pattern from handling Stripe disputes and chargebacks — same signature, different events.

FAQ#

How does Stripe Identity verification work?

Stripe Identity is a hosted verification flow that captures a user's government ID and a selfie, then runs document authenticity checks and a selfie liveness match. You create a verification session via the API, redirect the user to Stripe's hosted page, and Stripe pushes the final outcome (verified, requires_input, or canceled) to your webhook endpoint. The verified result is stored as a signed verified_outputs block on the session — accessible via the API but never written back to your database unless you explicitly retrieve it.

Can I integrate Stripe Identity into Laravel without Cashier?

Yes — Identity is independent of Cashier. You only need the stripe/stripe-php SDK. Cashier is purely a subscriptions and billing wrapper; it doesn't expose Identity helpers. If Cashier is already installed you can reuse its singleton StripeClient and webhook signature verification, but for an Identity-only integration a direct install of stripe/stripe-php is lighter and easier to reason about.

How do I handle Stripe Identity webhook events securely?

Use Stripe\Webhook::constructEvent() to verify the signed Stripe-Signature header against the raw request body and the endpoint's signing secret. Reject any request that fails signature verification with a 400 response. Exclude the webhook route from CSRF protection in bootstrap/app.php. Never trust the payload's metadata.user_id for authorization without re-querying your own database, and treat the handler as idempotent — Stripe may retry events, and reapplying the same event should produce the same database state.

What does Stripe Identity cost per verification?

Stripe Identity costs $1.50 per completed verification in the US, billed against your normal Stripe balance at the end of the month. That single fee covers both the ID document scan and the selfie liveness check — there are no separate charges for the two steps. Failed sessions where the user abandons before completion are not billed. Pricing varies slightly by region; check the live pricing page for your target market before budgeting.

Where do I store Stripe Identity results in my database?

Store the verification_session_id and a verified_at timestamp on the user — nothing more. The raw PII (name, DOB, address, document images) lives in Stripe's vault and is exposed only via the API when you retrieve the session with verified_outputs expanded. Keeping the source of truth at Stripe minimises your GDPR surface area, simplifies subject access requests, and means a database breach can't leak verified identity data.

How do I retry a failed identity verification session?

Verification sessions are one-shot by design — once a session has emitted requires_input or canceled, you cannot resume it. Create a brand new session via the same start route and replace the user's stripe_identity_session_id. Surface the failure reason from session.last_error.code so the user knows whether they need a better-lit photo, a different document type, or a clearer selfie before they try again.

Is Stripe Identity GDPR compliant for European users?

Stripe operates Identity under its general Data Processing Agreement, which covers GDPR obligations for EEA, UK, and Swiss users. You enter into the DPA as a Business User and Stripe acts as a processor for the identity data it collects. Storing only the session id on your side — rather than the raw verified outputs — keeps your GDPR exposure minimal and routes subject access requests through Stripe's existing data tooling. Review the current Stripe Privacy Center and DPA before launching in a regulated EU market.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.