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.
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 are passkeys? (1Password Blog)
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/browser2composer 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:
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 Model10{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(): Attribute29 {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.axios25 // Ask for the registration options26 .post('/registration/options', {27 username: this.username,28 })29 // Prompt the user to create a passkey30 .then((response) => startRegistration(response.data))31 // Verify the data with the server32 .then((attResp) => axios.post('/registration/verify', attResp))33 .then((verificationResponse) => {34 if (verificationResponse.data?.verified) {35 // If we're good, reload the page and36 // the server will redirect us to the dashboard37 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.axios49 // Ask for the authentication options50 .post('/authentication/options', {51 username: this.username,52 })53 // Prompt the user to authenticate with their passkey54 .then((response) => startAuthentication(response.data))55 // Verify the data with the server56 .then((attResp) =>57 axios.post('/authentication/verify', attResp),58 )59 .then((verificationResponse) => {60 // If we're good, reload the page and61 // the server will redirect us to the dashboard62 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 credentials101 $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 device122 $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 device130 $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 will145 // throw an exception if the response is invalid.146 // For the purposes of this demo, we are letting147 // the exception bubble up so we can see what is148 // 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 session162 $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 credentials101 $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 device122 $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 device130 $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 will145 // throw an exception if the response is invalid.146 // For the purposes of this demo, we are letting147 // the exception bubble up so we can see what is148 // 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 session162 $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 to102 $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 $serializedOptions109 );110 111 return $serializedOptions;112 }
113 114 public function verify(Request $request, ServerRequestInterface $serverRequest)115 {116 // This is a repo of our public key credentials117 $pkSourceRepo = new CredentialSourceRepository();118 119 $attestationManager = AttestationStatementSupportManager::create();120 $attestationManager->add(NoneAttestationStatementSupport::create());121 122 // The validator that will check the response from the device123 $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 device131 $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 will146 // throw an exception if the response is invalid.147 // For the purposes of this demo, we are letting148 // the exception bubble up so we can see what is149 // going on.150 $publicKeyCredentialSource = $responseValidator->check(151 $authenticatorAttestationResponse,152 PublicKeyCredentialCreationOptions::createFromArray(153 session(self::CREDENTIAL_CREATION_OPTIONS_SESSION_KEY)154 ),155 $serverRequest156 );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 session161 $request->session()->forget(self::CREDENTIAL_CREATION_OPTIONS_SESSION_KEY);162 163 // Save the user and the public key credential source to the database164 $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 to101 $serializedOptions['authenticatorSelection']['residentKey'] = AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_PREFERRED;102 103 // It is important to store the user entity and the options object in the session104 // 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 $serializedOptions108 );109 110 return $serializedOptions;111 }112 113 public function verify(Request $request, ServerRequestInterface $serverRequest)114 {115 // A repo of our public key credentials116 $pkSourceRepo = new CredentialSourceRepository();117 118 $attestationManager = AttestationStatementSupportManager::create();119 $attestationManager->add(NoneAttestationStatementSupport::create());120 121 // The validator that will check the response from the device122 $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 device130 $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 will145 // throw an exception if the response is invalid.146 // For the purposes of this demo, we are letting147 // the exception bubble up so we can see what is148 // going on.149 $publicKeyCredentialSource = $responseValidator->check(150 $authenticatorAttestationResponse,151 PublicKeyCredentialCreationOptions::createFromArray(152 session(self::CREDENTIAL_CREATION_OPTIONS_SESSION_KEY)153 ),154 $serverRequest155 );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 session160 $request->session()->forget(self::CREDENTIAL_CREATION_OPTIONS_SESSION_KEY);161 162 // Save the user and the public key credential source to the database163 $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 PublicKeyCredentialSourceRepository12{13 public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource14 {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): array28 {29 return User::with('authenticators')30 ->where('id', $publicKeyCredentialUserEntity->getId())31 ->first()32 ->authenticators33 ->toArray();34 }35 36 public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void37 {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 see21 </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 Register32 </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: