Implementing Passkey Authentication in Your Laravel App

Passwordless is the future of authentication. Let's learn how to implement passkey authentication in your Laravel app, allowing your users to create accounts and log in using only their devices.

Joe Tannenbaum

Passwords. Can't live with them, can't without them. Well, soon enough we can. Live without them, that is.

Passkey support is becoming more widely adopted. 1Password is soon to be 0Password. Browsers are catching up. We're not going to dive too deeply into what passkey authentication is in this article as much ink has been spilled on the topic, but the basics are:

  • You no longer enter a password to create an account/authenticate on a website

  • Instead, you use a device of your choosing (phone, tablet, computer, etc)

  • Your device will use biometrics to verify it's you

  • Private and public keys are created and exchanged between the device and the server

Some additional reading on what passkeys are and how they work before we keep going:

What We're Building

This demo is meant to serve as a proof-of-concept to authenticate our app's users with passkeys in Laravel. I wouldn't go deleting the password field on your users table just yet, but that vision of the future is getting closer and closer.

First, here's a link to a working demo of what we'll be building. The data is deleted every 30 minutes, and the repo is public, so go ahead and give it a spin:

Setting the Stage

We'll be utilizing the WebAuthn API, which involves both client-side and server-side components.

On the client side, we'll be using the SimpleWebAuthn browser package. On the server side, we'll implement the Webauthn Framework from Spomky Labs.

Heads up: This article is code-heavy, most of the explanation is via comments in the code itself.

So grab your coffee and let's get to it! Let's install our dependencies:

1yarn add @simplewebauthn/browser
2composer require web-auth/webauthn-lib

We'll also be using Alpine.js and to keep things simple we'll just include it from the CDN. Off we go.

Authentication Flow

There are several variations for handling the passkey authentication flow, but this is the flow we're going to use:

Here's what it looks like in action:

Look Ma, no password.
Look Ma, no password.

Database

We're going to focus on just two tables for the purposes of this demo, users and authenticators.

Users

Here is the migration for our users table, can you spot what's missing? 👀

1<?php
2 ...
3use Illuminate\Database\Migrations\Migration;
4use Illuminate\Database\Schema\Blueprint;
5use Illuminate\Support\Facades\Schema;
6 
7return new class extends Migration
8{
9 public function up()
10 {
11 Schema::create('users', function (Blueprint $table) {
12 $table->id();
13 $table->string('username')->unique();
14 $table->rememberToken();
15 $table->timestamps();
16 });
17 } ...
18 
19 public function down()
20 {
21 Schema::dropIfExists('users');
22 }
23};

Authenticators

Users can authenticate on multiple devices, so we'll need to store the public key sent to us by each device. We'll create an authenticators table and associate it with the user's record. Here's our migration:

1<?php
2 ...
3use Illuminate\Database\Migrations\Migration;
4use Illuminate\Database\Schema\Blueprint;
5use Illuminate\Support\Facades\Schema;
6 
7return new class extends Migration
8{
9 public function up()
10 {
11 Schema::create('authenticators', function (Blueprint $table) {
12 $table->id();
13 $table->foreignId('user_id')->constrained()->onDelete('cascade');
14 $table->text('credential_id');
15 $table->text('public_key');
16 $table->timestamps();
17 });
18 } ...
19 
20 public function down()
21 {
22 Schema::dropIfExists('authenticators');
23 }
24};

And here's our model:

1<?php
2 
3namespace App\Models;
4 ...
5use Illuminate\Database\Eloquent\Casts\Attribute;
6use Illuminate\Database\Eloquent\Factories\HasFactory;
7use Illuminate\Database\Eloquent\Model;
8 
9class Authenticator extends Model
10{
11 use HasFactory;
12 
13 protected $fillable = [
14 'credential_id',
15 'public_key',
16 ];
17 
18 protected $casts = [
19 'credential_id' => 'encrypted',
20 'public_key' => 'encrypted:json',
21 ];
22 
23 public function user()
24 {
25 return $this->belongsTo(User::class);
26 }
27 
28 public function credentialId(): Attribute
29 {
30 return new Attribute(
31 get: fn ($value) => base64_decode($value),
32 set: fn ($value) => base64_encode($value),
33 );
34 }
35}

Note that we're encrypting both the credential_id and the public_key. We technically don't have to, these values are virtually useless without the private key on the user's device, but I like to encrypt this sort of data sitting at rest. Helps me sleep better at night.

JavaScript Side

Here's the entirety of our app.js that powers the client side of this demo:

1import {
2 startAuthentication,
3 startRegistration,
4 browserSupportsWebAuthn,
5} from '@simplewebauthn/browser';
6import './bootstrap';
7 
8document.addEventListener('alpine:init', () => {
9 Alpine.data('authForm', () => ({
10 mode: 'login',
11 username: '',
12 browserSupported: browserSupportsWebAuthn(),
13 error: null,
14 submit() {
15 this.error = null;
16 
17 if (this.mode === 'login') {
18 return this.submitLogin();
19 }
20 
21 return this.submitRegister();
22 },
23 submitRegister() {
24 window.axios
25 // Ask for the registration options
26 .post('/registration/options', {
27 username: this.username,
28 })
29 // Prompt the user to create a passkey
30 .then((response) => startRegistration(response.data))
31 // Verify the data with the server
32 .then((attResp) => axios.post('/registration/verify', attResp))
33 .then((verificationResponse) => {
34 if (verificationResponse.data?.verified) {
35 // If we're good, reload the page and
36 // the server will redirect us to the dashboard
37 return window.location.reload();
38 }
39 
40 this.error =
41 'Something went wrong verifying the registration.';
42 })
43 .catch((error) => {
44 this.error = error?.response?.data?.message || error;
45 });
46 },
47 submitLogin() {
48 window.axios
49 // Ask for the authentication options
50 .post('/authentication/options', {
51 username: this.username,
52 })
53 // Prompt the user to authenticate with their passkey
54 .then((response) => startAuthentication(response.data))
55 // Verify the data with the server
56 .then((attResp) =>
57 axios.post('/authentication/verify', attResp),
58 )
59 .then((verificationResponse) => {
60 // If we're good, reload the page and
61 // the server will redirect us to the dashboard
62 if (verificationResponse.data?.verified) {
63 return window.location.reload();
64 }
65 
66 this.error =
67 'Something went wrong verifying the authentication.';
68 })
69 .catch((error) => {
70 const errorMessage =
71 error?.response?.data?.message || error;
72 
73 if (errorMessage === 'User not found') {
74 this.mode = 'confirmRegistration';
75 return;
76 }
77 
78 this.error = error?.response?.data?.message || error;
79 });
80 },
81 }));
82});

Authentication Controller

Generating Authentication Options

Here we're telling the device what we're able to handle vis-à-vis authentication algorithms, identifying our app to the device, and also laying the groundwork for future verification. This route is fired off after the user enters a username:

1<?php
2 
3namespace App\Http\Controllers;
4 ...
5// Holy imports, Batman
6use App\Auth\CredentialSourceRepository;
7use App\Models\User;
8use Cose\Algorithm\Manager;
9use Cose\Algorithm\Signature\ECDSA\ES256;
10use Cose\Algorithm\Signature\ECDSA\ES256K;
11use Cose\Algorithm\Signature\ECDSA\ES384;
12use Cose\Algorithm\Signature\ECDSA\ES512;
13use Cose\Algorithm\Signature\EdDSA\Ed256;
14use Cose\Algorithm\Signature\EdDSA\Ed512;
15use Cose\Algorithm\Signature\RSA\PS256;
16use Cose\Algorithm\Signature\RSA\PS384;
17use Cose\Algorithm\Signature\RSA\PS512;
18use Cose\Algorithm\Signature\RSA\RS256;
19use Cose\Algorithm\Signature\RSA\RS384;
20use Cose\Algorithm\Signature\RSA\RS512;
21use Illuminate\Database\Eloquent\ModelNotFoundException;
22use Illuminate\Http\Request;
23use Illuminate\Support\Facades\Auth;
24use Illuminate\Validation\ValidationException;
25use Psr\Http\Message\ServerRequestInterface;
26use Webauthn\AttestationStatement\AttestationObjectLoader;
27use Webauthn\AttestationStatement\AttestationStatementSupportManager;
28use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
29use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
30use Webauthn\AuthenticatorAssertionResponse;
31use Webauthn\AuthenticatorAssertionResponseValidator;
32use Webauthn\PublicKeyCredentialDescriptor;
33use Webauthn\PublicKeyCredentialLoader;
34use Webauthn\PublicKeyCredentialRequestOptions;
35use Webauthn\PublicKeyCredentialSource;
36use Webauthn\PublicKeyCredentialUserEntity;
37use Webauthn\TokenBinding\IgnoreTokenBindingHandler;
38 
39class AuthenticationController extends Controller
40{
41 // We use this key across several methods, so we're going to define it here
42 const CREDENTIAL_REQUEST_OPTIONS_SESSION_KEY = 'publicKeyCredentialRequestOptions';
43 
44 public function generateOptions(Request $request)
45 {
46 try {
47 $user = User::where('username', $request->input('username'))->firstOrFail();
48 } catch (ModelNotFoundException $e) {
49 throw ValidationException::withMessages([
50 'username' => 'User not found',
51 ]);
52 }
53 
54 // User Entity
55 $userEntity = PublicKeyCredentialUserEntity::create(
56 $user->username,
57 (string) $user->id,
58 $user->username,
59 null,
60 );
61 
62 // A repo of our public key credentials
63 $pkSourceRepo = new CredentialSourceRepository();
64 
65 // A user can have multiple authenticators, so we need to get all of them to check against
66 $registeredAuthenticators = $pkSourceRepo->findAllForUserEntity($userEntity);
67 
68 // We don’t need the Credential Sources, just the associated Descriptors
69 $allowedCredentials = collect($registeredAuthenticators)
70 ->pluck('public_key')
71 ->map(
72 fn ($publicKey) => PublicKeyCredentialSource::createFromArray($publicKey)
73 )
74 ->map(
75 fn (PublicKeyCredentialSource $credential): PublicKeyCredentialDescriptor => $credential->getPublicKeyCredentialDescriptor()
76 )
77 ->toArray();
78 
79 $pkRequestOptions =
80 PublicKeyCredentialRequestOptions::create(
81 random_bytes(32) // Challenge
82 )
83 // Tell the device which authenticators we are allowed to use
84 ->allowCredentials(...$allowedCredentials);
85 
86 $serializedOptions = $pkRequestOptions->jsonSerialize();
87 
88 // It is important to store the the options object in the session
89 // for the next step. The data will be needed to check the response from the device.
90 $request->session()->put(
91 self::CREDENTIAL_REQUEST_OPTIONS_SESSION_KEY,
92 $serializedOptions
93 );
94 
95 return $serializedOptions;
96 } ...
97 
98 public function verify(Request $request, ServerRequestInterface $serverRequest)
99 {
100 // A repo of our public key credentials
101 $pkSourceRepo = new CredentialSourceRepository();
102 
103 $attestationManager = AttestationStatementSupportManager::create();
104 $attestationManager->add(NoneAttestationStatementSupport::create());
105 
106 $algorithmManager = Manager::create()->add(
107 ES256::create(),
108 ES256K::create(),
109 ES384::create(),
110 ES512::create(),
111 RS256::create(),
112 RS384::create(),
113 RS512::create(),
114 PS256::create(),
115 PS384::create(),
116 PS512::create(),
117 Ed256::create(),
118 Ed512::create(),
119 );
120 
121 // The validator that will check the response from the device
122 $responseValidator = AuthenticatorAssertionResponseValidator::create(
123 $pkSourceRepo,
124 IgnoreTokenBindingHandler::create(),
125 ExtensionOutputCheckerHandler::create(),
126 $algorithmManager,
127 );
128 
129 // A loader that will load the response from the device
130 $pkCredentialLoader = PublicKeyCredentialLoader::create(
131 AttestationObjectLoader::create($attestationManager)
132 );
133 
134 $publicKeyCredential = $pkCredentialLoader->load(json_encode($request->all()));
135 
136 $authenticatorAssertionResponse = $publicKeyCredential->getResponse();
137 
138 if (!$authenticatorAssertionResponse instanceof AuthenticatorAssertionResponse) {
139 throw ValidationException::withMessages([
140 'username' => 'Invalid response type',
141 ]);
142 }
143 
144 // Check the response from the device, this will
145 // throw an exception if the response is invalid.
146 // For the purposes of this demo, we are letting
147 // the exception bubble up so we can see what is
148 // going on.
149 $publicKeyCredentialSource = $responseValidator->check(
150 $publicKeyCredential->getRawId(),
151 $authenticatorAssertionResponse,
152 PublicKeyCredentialRequestOptions::createFromArray(
153 session(self::CREDENTIAL_REQUEST_OPTIONS_SESSION_KEY)
154 ),
155 $serverRequest,
156 $authenticatorAssertionResponse->getUserHandle(),
157 );
158 
159 // If we've gotten this far, the response is valid!
160 
161 // We don't need the options anymore, so let's remove them from the session
162 $request->session()->forget(self::CREDENTIAL_REQUEST_OPTIONS_SESSION_KEY);
163 
164 $user = User::where('username', $publicKeyCredentialSource->getUserHandle())->firstOrFail();
165 
166 Auth::login($user);
167 
168 return [
169 'verified' => true,
170 ];
171 }
172}

Verifying Authentication Data

After the user has selected a key to authenticate with, it reports back to the server to double-check that everything looks legit:

1<?php
2 
3namespace App\Http\Controllers;
4 ...
5// Holy imports, Batman
6use App\Auth\CredentialSourceRepository;
7use App\Models\User;
8use Cose\Algorithm\Manager;
9use Cose\Algorithm\Signature\ECDSA\ES256;
10use Cose\Algorithm\Signature\ECDSA\ES256K;
11use Cose\Algorithm\Signature\ECDSA\ES384;
12use Cose\Algorithm\Signature\ECDSA\ES512;
13use Cose\Algorithm\Signature\EdDSA\Ed256;
14use Cose\Algorithm\Signature\EdDSA\Ed512;
15use Cose\Algorithm\Signature\RSA\PS256;
16use Cose\Algorithm\Signature\RSA\PS384;
17use Cose\Algorithm\Signature\RSA\PS512;
18use Cose\Algorithm\Signature\RSA\RS256;
19use Cose\Algorithm\Signature\RSA\RS384;
20use Cose\Algorithm\Signature\RSA\RS512;
21use Illuminate\Database\Eloquent\ModelNotFoundException;
22use Illuminate\Http\Request;
23use Illuminate\Support\Facades\Auth;
24use Illuminate\Validation\ValidationException;
25use Psr\Http\Message\ServerRequestInterface;
26use Webauthn\AttestationStatement\AttestationObjectLoader;
27use Webauthn\AttestationStatement\AttestationStatementSupportManager;
28use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
29use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
30use Webauthn\AuthenticatorAssertionResponse;
31use Webauthn\AuthenticatorAssertionResponseValidator;
32use Webauthn\PublicKeyCredentialDescriptor;
33use Webauthn\PublicKeyCredentialLoader;
34use Webauthn\PublicKeyCredentialRequestOptions;
35use Webauthn\PublicKeyCredentialSource;
36use Webauthn\PublicKeyCredentialUserEntity;
37use Webauthn\TokenBinding\IgnoreTokenBindingHandler;
38 
39class AuthenticationController extends Controller
40{
41 // We use this key across several methods, so we're going to define it here
42 const CREDENTIAL_REQUEST_OPTIONS_SESSION_KEY = 'publicKeyCredentialRequestOptions';
43 ...
44 public function generateOptions(Request $request)
45 {
46 try {
47 $user = User::where('username', $request->input('username'))->firstOrFail();
48 } catch (ModelNotFoundException $e) {
49 throw ValidationException::withMessages([
50 'username' => 'User not found',
51 ]);
52 }
53 
54 // User Entity
55 $userEntity = PublicKeyCredentialUserEntity::create(
56 $user->username,
57 (string) $user->id,
58 $user->username,
59 null,
60 );
61 
62 // A repo of our public key credentials
63 $pkSourceRepo = new CredentialSourceRepository();
64 
65 // A user can have multiple authenticators, so we need to get all of them to check against
66 $registeredAuthenticators = $pkSourceRepo->findAllForUserEntity($userEntity);
67 
68 // We don’t need the Credential Sources, just the associated Descriptors
69 $allowedCredentials = collect($registeredAuthenticators)
70 ->pluck('public_key')
71 ->map(
72 fn ($publicKey) => PublicKeyCredentialSource::createFromArray($publicKey)
73 )
74 ->map(
75 fn (PublicKeyCredentialSource $credential): PublicKeyCredentialDescriptor => $credential->getPublicKeyCredentialDescriptor()
76 )
77 ->toArray();
78 
79 $pkRequestOptions =
80 PublicKeyCredentialRequestOptions::create(
81 random_bytes(32) // Challenge
82 )
83 // Tell the device which authenticators we are allowed to use
84 ->allowCredentials(...$allowedCredentials);
85 
86 $serializedOptions = $pkRequestOptions->jsonSerialize();
87 
88 // It is important to store the the options object in the session
89 // for the next step. The data will be needed to check the response from the device.
90 $request->session()->put(
91 self::CREDENTIAL_REQUEST_OPTIONS_SESSION_KEY,
92 $serializedOptions
93 );
94 
95 return $serializedOptions;
96 }
97 
98 public function verify(Request $request, ServerRequestInterface $serverRequest)
99 {
100 // A repo of our public key credentials
101 $pkSourceRepo = new CredentialSourceRepository();
102 
103 $attestationManager = AttestationStatementSupportManager::create();
104 $attestationManager->add(NoneAttestationStatementSupport::create());
105 
106 $algorithmManager = Manager::create()->add(
107 ES256::create(),
108 ES256K::create(),
109 ES384::create(),
110 ES512::create(),
111 RS256::create(),
112 RS384::create(),
113 RS512::create(),
114 PS256::create(),
115 PS384::create(),
116 PS512::create(),
117 Ed256::create(),
118 Ed512::create(),
119 );
120 
121 // The validator that will check the response from the device
122 $responseValidator = AuthenticatorAssertionResponseValidator::create(
123 $pkSourceRepo,
124 IgnoreTokenBindingHandler::create(),
125 ExtensionOutputCheckerHandler::create(),
126 $algorithmManager,
127 );
128 
129 // A loader that will load the response from the device
130 $pkCredentialLoader = PublicKeyCredentialLoader::create(
131 AttestationObjectLoader::create($attestationManager)
132 );
133 
134 $publicKeyCredential = $pkCredentialLoader->load(json_encode($request->all()));
135 
136 $authenticatorAssertionResponse = $publicKeyCredential->getResponse();
137 
138 if (!$authenticatorAssertionResponse instanceof AuthenticatorAssertionResponse) {
139 throw ValidationException::withMessages([
140 'username' => 'Invalid response type',
141 ]);
142 }
143 
144 // Check the response from the device, this will
145 // throw an exception if the response is invalid.
146 // For the purposes of this demo, we are letting
147 // the exception bubble up so we can see what is
148 // going on.
149 $publicKeyCredentialSource = $responseValidator->check(
150 $publicKeyCredential->getRawId(),
151 $authenticatorAssertionResponse,
152 PublicKeyCredentialRequestOptions::createFromArray(
153 session(self::CREDENTIAL_REQUEST_OPTIONS_SESSION_KEY)
154 ),
155 $serverRequest,
156 $authenticatorAssertionResponse->getUserHandle(),
157 );
158 
159 // If we've gotten this far, the response is valid!
160 
161 // We don't need the options anymore, so let's remove them from the session
162 $request->session()->forget(self::CREDENTIAL_REQUEST_OPTIONS_SESSION_KEY);
163 
164 $user = User::where('username', $publicKeyCredentialSource->getUserHandle())->firstOrFail();
165 
166 Auth::login($user);
167 
168 return [
169 'verified' => true,
170 ];
171 }
172}

Registration Controller

Generating Registration Options

We're basically doing all of the same things as the Authenticator's generate options method, just for registration instead:

1<?php
2 
3namespace App\Http\Controllers;
4 
5// Holy imports, Batman
6 ...
7use App\Auth\CredentialSourceRepository;
8use App\Models\User;
9use Cose\Algorithms;
10use Illuminate\Http\Request;
11use Illuminate\Support\Facades\Auth;
12use Illuminate\Validation\ValidationException;
13use Psr\Http\Message\ServerRequestInterface;
14use Webauthn\AttestationStatement\AttestationObjectLoader;
15use Webauthn\AttestationStatement\AttestationStatementSupportManager;
16use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
17use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
18use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
19use Webauthn\AuthenticatorAttestationResponse;
20use Webauthn\AuthenticatorAttestationResponseValidator;
21use Webauthn\AuthenticatorSelectionCriteria;
22use Webauthn\PublicKeyCredentialCreationOptions;
23use Webauthn\PublicKeyCredentialLoader;
24use Webauthn\PublicKeyCredentialParameters;
25use Webauthn\PublicKeyCredentialRpEntity;
26use Webauthn\PublicKeyCredentialUserEntity;
27use Webauthn\TokenBinding\IgnoreTokenBindingHandler;
28 
29class RegistrationController extends Controller
30{
31 // We use this key across several methods, so we're going to define it here
32 const CREDENTIAL_CREATION_OPTIONS_SESSION_KEY = 'publicKeyCredentialCreationOptions';
33 
34 public function generateOptions(Request $request)
35 {
36 // Relying Party Entity i.e. the application
37 $rpEntity = PublicKeyCredentialRpEntity::create(
38 config('app.name'),
39 parse_url(config('app.url'), PHP_URL_HOST),
40 null,
41 );
42 
43 // User Entity
44 $userEntity = PublicKeyCredentialUserEntity::create(
45 $request->input('username'),
46 $request->input('username'),
47 $request->input('username'),
48 null,
49 );
50 
51 // Challenge (random binary string)
52 $challenge = random_bytes(16);
53 
54 // List of supported public key parameters
55 $supportedPublicKeyParams = collect([
56 Algorithms::COSE_ALGORITHM_ES256,
57 Algorithms::COSE_ALGORITHM_ES256K,
58 Algorithms::COSE_ALGORITHM_ES384,
59 Algorithms::COSE_ALGORITHM_ES512,
60 Algorithms::COSE_ALGORITHM_RS256,
61 Algorithms::COSE_ALGORITHM_RS384,
62 Algorithms::COSE_ALGORITHM_RS512,
63 Algorithms::COSE_ALGORITHM_PS256,
64 Algorithms::COSE_ALGORITHM_PS384,
65 Algorithms::COSE_ALGORITHM_PS512,
66 Algorithms::COSE_ALGORITHM_ED256,
67 Algorithms::COSE_ALGORITHM_ED512,
68 ])->map(
69 fn ($algorithm) => PublicKeyCredentialParameters::create('public-key', $algorithm)
70 )->toArray();
71 
72 // Instantiate PublicKeyCredentialCreationOptions object
73 $pkCreationOptions =
74 PublicKeyCredentialCreationOptions::create(
75 $rpEntity,
76 $userEntity,
77 $challenge,
78 $supportedPublicKeyParams,
79 )
80 ->setAttestation(
81 PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE
82 )
83 ->setAuthenticatorSelection(
84 AuthenticatorSelectionCriteria::create()
85 )
86 ->setExtensions(AuthenticationExtensionsClientInputs::createFromArray([
87 'credProps' => true,
88 ]));
89 
90 $serializedOptions = $pkCreationOptions->jsonSerialize();
91 
92 if (!isset($serializedOptions['excludeCredentials'])) {
93 // The JS side needs this, so let's set it up for success with an empty array
94 $serializedOptions['excludeCredentials'] = [];
95 }
96 
97 // This library for some reason doesn't serialize the extensions object,
98 // so we'll do it manually
99 $serializedOptions['extensions'] = $serializedOptions['extensions']->jsonSerialize();
100 
101 // Another thing we have to do manually for this to work the way we want to
102 $serializedOptions['authenticatorSelection']['residentKey'] = AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED;
103 
104 // It is important to store the user entity and the options object (e.g. in the session)
105 // for the next step. The data will be needed to check the response from the device.
106 $request->session()->put(
107 self::CREDENTIAL_CREATION_OPTIONS_SESSION_KEY,
108 $serializedOptions
109 );
110 
111 return $serializedOptions;
112 } ...
113 
114 public function verify(Request $request, ServerRequestInterface $serverRequest)
115 {
116 // This is a repo of our public key credentials
117 $pkSourceRepo = new CredentialSourceRepository();
118 
119 $attestationManager = AttestationStatementSupportManager::create();
120 $attestationManager->add(NoneAttestationStatementSupport::create());
121 
122 // The validator that will check the response from the device
123 $responseValidator = AuthenticatorAttestationResponseValidator::create(
124 $attestationManager,
125 $pkSourceRepo,
126 IgnoreTokenBindingHandler::create(),
127 ExtensionOutputCheckerHandler::create(),
128 );
129 
130 // A loader that will load the response from the device
131 $pkCredentialLoader = PublicKeyCredentialLoader::create(
132 AttestationObjectLoader::create($attestationManager)
133 );
134 
135 $publicKeyCredential = $pkCredentialLoader->load(json_encode($request->all()));
136 
137 $authenticatorAttestationResponse = $publicKeyCredential->getResponse();
138 
139 if (!$authenticatorAttestationResponse instanceof AuthenticatorAttestationResponse) {
140 throw ValidationException::withMessages([
141 'username' => 'Invalid response type',
142 ]);
143 }
144 
145 // Check the response from the device, this will
146 // throw an exception if the response is invalid.
147 // For the purposes of this demo, we are letting
148 // the exception bubble up so we can see what is
149 // going on.
150 $publicKeyCredentialSource = $responseValidator->check(
151 $authenticatorAttestationResponse,
152 PublicKeyCredentialCreationOptions::createFromArray(
153 session(self::CREDENTIAL_CREATION_OPTIONS_SESSION_KEY)
154 ),
155 $serverRequest
156 );
157 
158 // If we've gotten this far, the response is valid!
159 
160 // We don't need the options anymore, so let's remove them from the session
161 $request->session()->forget(self::CREDENTIAL_CREATION_OPTIONS_SESSION_KEY);
162 
163 // Save the user and the public key credential source to the database
164 $user = User::create([
165 'username' => $publicKeyCredentialSource->getUserHandle(),
166 ]);
167 
168 $pkSourceRepo->saveCredentialSource($publicKeyCredentialSource);
169 
170 Auth::login($user);
171 
172 return [
173 'verified' => true,
174 ];
175 }
176}

Verifying Registration Data

After the user has created a key with the device, it reports back to the server to double-check that everything looks legit:

1<?php
2 
3namespace App\Http\Controllers;
4 ...
5// Holy imports, Batman
6use App\Auth\CredentialSourceRepository;
7use App\Models\User;
8use Cose\Algorithms;
9use Illuminate\Http\Request;
10use Illuminate\Support\Facades\Auth;
11use Illuminate\Validation\ValidationException;
12use Psr\Http\Message\ServerRequestInterface;
13use Webauthn\AttestationStatement\AttestationObjectLoader;
14use Webauthn\AttestationStatement\AttestationStatementSupportManager;
15use Webauthn\AttestationStatement\NoneAttestationStatementSupport;
16use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
17use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
18use Webauthn\AuthenticatorAttestationResponse;
19use Webauthn\AuthenticatorAttestationResponseValidator;
20use Webauthn\AuthenticatorSelectionCriteria;
21use Webauthn\PublicKeyCredentialCreationOptions;
22use Webauthn\PublicKeyCredentialLoader;
23use Webauthn\PublicKeyCredentialParameters;
24use Webauthn\PublicKeyCredentialRpEntity;
25use Webauthn\PublicKeyCredentialUserEntity;
26use Webauthn\TokenBinding\IgnoreTokenBindingHandler;
27 
28class RegistrationController extends Controller
29{
30 // We use this key across several methods, so we're going to define it here
31 const CREDENTIAL_CREATION_OPTIONS_SESSION_KEY = 'publicKeyCredentialCreationOptions';
32 ...
33 public function generateOptions(Request $request)
34 {
35 // Relying Party Entity i.e. the application
36 $rpEntity = PublicKeyCredentialRpEntity::create(
37 config('app.name'),
38 parse_url(config('app.url'), PHP_URL_HOST),
39 null,
40 );
41 
42 // User Entity
43 $userEntity = PublicKeyCredentialUserEntity::create(
44 $request->input('username'),
45 $request->input('username'),
46 $request->input('username'),
47 null,
48 );
49 
50 // Challenge (random binary string)
51 $challenge = random_bytes(16);
52 
53 // List of supported public key parameters
54 $supportedPublicKeyParams = collect([
55 Algorithms::COSE_ALGORITHM_ES256,
56 Algorithms::COSE_ALGORITHM_ES256K,
57 Algorithms::COSE_ALGORITHM_ES384,
58 Algorithms::COSE_ALGORITHM_ES512,
59 Algorithms::COSE_ALGORITHM_RS256,
60 Algorithms::COSE_ALGORITHM_RS384,
61 Algorithms::COSE_ALGORITHM_RS512,
62 Algorithms::COSE_ALGORITHM_PS256,
63 Algorithms::COSE_ALGORITHM_PS384,
64 Algorithms::COSE_ALGORITHM_PS512,
65 Algorithms::COSE_ALGORITHM_ED256,
66 Algorithms::COSE_ALGORITHM_ED512,
67 ])->map(
68 fn ($algorithm) => PublicKeyCredentialParameters::create('public-key', $algorithm)
69 )->toArray();
70 
71 // Instantiate PublicKeyCredentialCreationOptions object
72 $pkCreationOptions =
73 PublicKeyCredentialCreationOptions::create(
74 $rpEntity,
75 $userEntity,
76 $challenge,
77 $supportedPublicKeyParams,
78 )
79 ->setAttestation(
80 PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE
81 )
82 ->setAuthenticatorSelection(
83 AuthenticatorSelectionCriteria::create()
84 )
85 ->setExtensions(AuthenticationExtensionsClientInputs::createFromArray([
86 'credProps' => true,
87 ]));
88 
89 $serializedOptions = $pkCreationOptions->jsonSerialize();
90 
91 if (!isset($serializedOptions['excludeCredentials'])) {
92 // The JS side needs this, so let's set it up for success with an empty array
93 $serializedOptions['excludeCredentials'] = [];
94 }
95 
96 // This library for some reason doesn't serialize the extensions object,
97 // so we'll do it manually
98 $serializedOptions['extensions'] = $serializedOptions['extensions']->jsonSerialize();
99 
100 // Another thing we have to do manually for this to work the way we want to
101 $serializedOptions['authenticatorSelection']['residentKey'] = AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED;
102 
103 // It is important to store the user entity and the options object in the session
104 // for the next step. The data will be needed to check the response from the device.
105 $request->session()->put(
106 self::CREDENTIAL_CREATION_OPTIONS_SESSION_KEY,
107 $serializedOptions
108 );
109 
110 return $serializedOptions;
111 }
112 
113 public function verify(Request $request, ServerRequestInterface $serverRequest)
114 {
115 // A repo of our public key credentials
116 $pkSourceRepo = new CredentialSourceRepository();
117 
118 $attestationManager = AttestationStatementSupportManager::create();
119 $attestationManager->add(NoneAttestationStatementSupport::create());
120 
121 // The validator that will check the response from the device
122 $responseValidator = AuthenticatorAttestationResponseValidator::create(
123 $attestationManager,
124 $pkSourceRepo,
125 IgnoreTokenBindingHandler::create(),
126 ExtensionOutputCheckerHandler::create(),
127 );
128 
129 // A loader that will load the response from the device
130 $pkCredentialLoader = PublicKeyCredentialLoader::create(
131 AttestationObjectLoader::create($attestationManager)
132 );
133 
134 $publicKeyCredential = $pkCredentialLoader->load(json_encode($request->all()));
135 
136 $authenticatorAttestationResponse = $publicKeyCredential->getResponse();
137 
138 if (!$authenticatorAttestationResponse instanceof AuthenticatorAttestationResponse) {
139 throw ValidationException::withMessages([
140 'username' => 'Invalid response type',
141 ]);
142 }
143 
144 // Check the response from the device, this will
145 // throw an exception if the response is invalid.
146 // For the purposes of this demo, we are letting
147 // the exception bubble up so we can see what is
148 // going on.
149 $publicKeyCredentialSource = $responseValidator->check(
150 $authenticatorAttestationResponse,
151 PublicKeyCredentialCreationOptions::createFromArray(
152 session(self::CREDENTIAL_CREATION_OPTIONS_SESSION_KEY)
153 ),
154 $serverRequest
155 );
156 
157 // If we've gotten this far, the response is valid!
158 
159 // We don't need the options anymore, so let's remove them from the session
160 $request->session()->forget(self::CREDENTIAL_CREATION_OPTIONS_SESSION_KEY);
161 
162 // Save the user and the public key credential source to the database
163 $user = User::create([
164 'username' => $publicKeyCredentialSource->getUserHandle(),
165 ]);
166 
167 $pkSourceRepo->saveCredentialSource($publicKeyCredentialSource);
168 
169 Auth::login($user);
170 
171 return [
172 'verified' => true,
173 ];
174 }
175}

Credential Source Repository

Next up, we have the CredentialSourceRepository, which we are required to implement by our WebAuthn PHP library. This little class allows us to easily search and save our credentials as we authenticate users:

1<?php
2 
3namespace App\Auth;
4 ...
5use App\Models\Authenticator;
6use App\Models\User;
7use Webauthn\PublicKeyCredentialSource;
8use Webauthn\PublicKeyCredentialSourceRepository;
9use Webauthn\PublicKeyCredentialUserEntity;
10 
11class CredentialSourceRepository implements PublicKeyCredentialSourceRepository
12{
13 public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
14 {
15 $authenticator = Authenticator::where(
16 'credential_id',
17 base64_encode($publicKeyCredentialId)
18 )->first();
19 
20 if (!$authenticator) {
21 return null;
22 }
23 
24 return PublicKeyCredentialSource::createFromArray($authenticator->public_key);
25 }
26 
27 public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array
28 {
29 return User::with('authenticators')
30 ->where('id', $publicKeyCredentialUserEntity->getId())
31 ->first()
32 ->authenticators
33 ->toArray();
34 }
35 
36 public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
37 {
38 $user = User::where(
39 'username',
40 $publicKeyCredentialSource->getUserHandle()
41 )->firstOrFail();
42 
43 $user->authenticators()->save(new Authenticator([
44 'credential_id' => $publicKeyCredentialSource->getPublicKeyCredentialId(),
45 'public_key' => $publicKeyCredentialSource->jsonSerialize(),
46 ]));
47 }
48}

The View

And finally, we come to our view file, stripped of its Tailwind classes for clarity:

1<div x-data="authForm" x-cloak>
2 <div x-show="!browserSupported">
3 <div>
4 <div>
5 ...
6 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
7 <path fill-rule="evenodd"
8 d="M8.485 3.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 3.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z"
9 clip-rule="evenodd" />
10 </svg>
11 </div>
12 <div>
13 <h3>
14 Your browser isn't supported!
15 </h3>
16 <div>
17 <p>
18 That's sort of a bummer, sorry. Maybe you have access to a browser that does though,
19 <a target="_blank" href="https://caniuse.com/?search=webauthn">
20 check and see
21 </a>.
22 </p>
23 </div>
24 </div>
25 </div>
26 </div>
27 
28 <div x-show="browserSupported">
29 <form @submit.prevent="submit">
30 <h2>
31 Sign In or Register
32 </h2>
33 <div x-show="mode === 'login'">
34 <label for="email">Username</label>
35 <div>
36 <input x-model="username" type="text" id="username" v-model="username" autocomplete="username"
37 required autocapitalize="off" />
38 ...
39 <div x-show="error">
40 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
41 aria-hidden="true">
42 <path fill-rule="evenodd"
43 d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
44 clip-rule="evenodd" />
45 </svg>
46 </div>
47
48 </div>
49 <p x-show="error" x-text="error"></p>
50 </div>
51 
52 <div x-show="mode === 'confirmRegistration'">
53 <p>No account exists for "<span x-text="username"></span>".
54 <p>Do you want to create a new account?</p>
55 <p>
56 <a href="#" @click.prevent="mode = 'login'">Cancel</a>
57 </p>
58 </div>
59 
60 <div>
61 <button type="submit" x-text="mode === 'confirmRegistration' ? 'Register' : 'Continue'">
62 </button>
63 </div>
64 </form>
65 </div>
66</div>

Wrapping Up

And that's it! A basic implementation of passkey authentication for your Laravel app. Let's take a look at our beautiful passwordless future one more time:

(Excellent) syntax highlighting provided by Torchlight