Implementing Spatie's Laravel-Passkey package in Inertia (Vue)

Implementing passkeys in Inertia
Spatie’s Laravel-Passkey package makes implementing passkeys easy, but the documentation for implementing it with Inertia didn’t really do it for me. So, here is how I cleanly implemented it for an Inertia project with VueJS, Typescript.
Installation
Run the following:
composer require spatie/laravel-passkeys
php artisan vendor:publish --tag="passkeys-migrations"
php artisan migrate
npm install @simplewebauthn/browser
User Model Updates
Don’t forget this step - kind of important:
// make sure you have HasPassKeys
class User extends Authenticatable implements MustVerifyEmail, HasPasskeys
{
// and the trait InteractsWithPasskeys
use HasFactory, Notifiable, InteractsWithPasskeys;
}
Setting Up Typescript
Go into your resources/js/types/global.d.ts
file and drop in the following (or add to Window if it exists):
declare global {
interface Window {
browserSupportsWebAuthn?: () => boolean;
startAuthentication: (options: any) => Promise<AuthenticationResponseJSON>;
startRegistration: (options: any) => Promise<RegistrationResponseJSON>;
}
}
From here, open up the page(s) where you need to interface with passkeys. For me, I used pages Passkeys.vue
and Login.vue
:
import { browserSupportsWebAuthn, startAuthentication, startRegistration } from '@simplewebauthn/browser';
window.browserSupportsWebAuthn = browserSupportsWebAuthn;
window.startAuthentication = startAuthentication;
window.startRegistration = startRegistration;
Configuring Routes
Under the hood of the package, Spatie registers a few default routes via it’s ServiceProvider. It looks like this:
// vendor/spatie/laravel-passkeys/src/LaravelPasskeysServiceProvider.php
protected function registerPasskeyRouteMacro(): self
{
Route::macro('passkeys', function (string $prefix = 'passkeys') {
Route::prefix($prefix)->group(function () {
Route::get(
'authentication-options',
GeneratePasskeyAuthenticationOptionsController::class
)->name('passkeys.authentication_options');
Route::post(
'authenticate',
AuthenticateUsingPasskeyController::class
)->name('passkeys.login');
});
});
return $this;
}
We just need to register the routes. In routes/web.php
// Register default routes
Route::passkeys();
There are some things we do need, though: the views and a single point for Registering
a passkey. For this, I added them to routes/settings.php
but you can include them anywhere in your application. Here are all three you’ll want to register:
// This is the page for my users to view the passkeys
Route::get('settings/passkeys', [ProfileController::class, 'viewPassKeys'])->name('settings.passkeys');
// This is an endpoint to store a new passkey
Route::post('settings/passkeys', [ProfileController::class, 'storePasskey'])->name('settings.passkeys.store');
// This is the endpoint to remove a passkey that was added to an account
Route::delete('settings/passkeys/{passkey}', [ProfileController::class, 'deletePassKey'])->name('settings.passkeys.delete');
// Register endpoint - don't forget this.
Route::get('passkey-options', [AuthenticatedSessionController::class, 'passkeyOptions'])->name('auth.passkeys.authentication_options');
Adding Passkeys
Ok, let’s jump into the inertia end in Passkeys.vue
. Since I’m using Ziggy, my routes are available via the route
function. Since it’s registered with a name, we can drop it in.
const addPassKey = async () => {
// use auth.passkeys.authentication_options - NOT the other one. The other one is for logging in.
const passkeyOptions = await fetch(route('auth.passkeys.authentication_options'));
const options = await passkeyOptions.json();
const startAuthenticatingPasskey = await startRegistration({
optionsJSON: options
});
// Use inertias FormHelper to create requests
const form = useForm({
options: JSON.stringify(options),
passkey: JSON.stringify(startAuthenticatingPasskey),
});
// call our store function
form.post(route('settings.passkeys.store'), {
preserveScroll: true,
});
};
And here is the PHP side. You need both of these.
// inside of AuthenticatedSessionController
public function passkeyOptions(Request $request)
{
return app(GeneratePasskeyRegisterOptionsAction::class)->execute($request->user());
}
// my settings controller
public function storePasskey(Request $request)
{
$request->validate([
'options' => 'required|json',
'passkey' => 'required|json',
]);
$storePasskeyAction = app(StorePasskeyAction::class);
$storePasskeyAction->execute(
$request->user(),
$request->input('passkey'),
$request->input('options'),
$request->getHost(),
// note: you can customize this if you want, just prompt for a name after you have the the JSON payloads ready.
['name' => $request->input('name', 'Passkey added on '.now()->toDateTimeString())]
);
return to_route('settings.passkeys')->with('success', 'Passkey added successfully!');
}
That’s it - you’re ready to go! Hook it up to a button and fire away:
<Button @click="addPassKey">
Add Passkey
</Button>
Removing Passkeys
This one is also simple - just make sure you’re actually scoped into the users passkeys.
public function deletePassKey(string $id, Request $request)
{
$request->user()->passkeys()->where('id', $id)->delete();
return to_route('settings.passkeys')->with('success', 'Passkey deleted successfully!');
}
Login
After making sure you can add a passkey, add a button to your Login.vue
file to login with passkeys. Here is all you need:
const loginWithPasskey = async () => {
// remember: this route was given to us via the Spatie package. Not the other one for registering.
const authenticationOptions = await fetch(route('passkeys.authentication_options'));
const options = await authenticationOptions.json();
const startAuthenticationResponse = await window.startAuthentication({optionsJSON: options});
const form = useForm({
start_authentication_response: JSON.stringify(startAuthenticationResponse),
});
// This one too was registered for us - we dont have to do anything
form.post(route('passkeys.login'));
};
And the button to kickstart the process:
<Button
v-if="browserSupportsWebAuthn()"
type="button"
class="mt-4 w-full"
variant="outline"
:disabled="form.processing"
@click="loginWithPasskey"
>
<LoaderCircle v-if="form.processing" class="h-4 w-4 animate-spin" />
Log in with Passkey
</Button>
All Done
That’s it! Inertia ready, simple passkeys.