Spatie has dropped a new passkeys package that does a lot of heavy lifting on adding Passkeys to your application, it ships with components for Livewire, but is currently missing components for Inertia so far.
So here’s a simple guide on how I added passkeys to our InertiaJS app in about 30 minutes:
Install the package.
Install the package as instructed, and once it’s installed, make sure you add the interface and traits to your User
model, or other authenticatable model.
List the user’s current passkeys
In your user’s settings area, or on a page of your choosing, return the user’s current passkeys to your inertia component.
But there’s a catch here, make sure you aren’t returning the data
or credential_id
columns - they can play havoc with JSON encoding:
$user = auth()->user();
return Inertia::render('Profile/Settings', [
'user' => $user,
'passkeys' => 'passkeys' => $user->passkeys()
->get()
->map(fn ($key) => $key->only(['id', 'name', 'last_used_at'])),
]);
Once you’ve done this, add a few more methods and routes to your profile controller - or to a new Passkeys specific controller:
// POST profile.passkeys.create
public function storePassKey();
// DELETE profile.passkeys.delete
public function deletePasskey(string $id);
// GET profile.passkeys.generate-options
public function generatePasskeyOptions();
Note: make sure these routes are protected by at least the
auth
middleware.
First - allow your users to add passkeys to their accounts
In your Settings
component, add a section for passkeys which list out the existing ones, and also have a button for adding a new one.
<script>
import {router} from '@inertiajs/svelte';
async function addPassKey() {
const response = await fetch(window.route("profile.passkeys.generate-options"));
const options = await response.json();
const startAuthenticationResponse = await window.startRegistration(options);
router.post(
window.route("profile.passkeys.store"),
{
options: JSON.stringify(options),
passkey: JSON.stringify(startAuthenticationResponse)
}
);
}
function deletePasskey(id) {
if (confirm("Are you SURE you want to delete this passkey?")) {
router.delete(
window.route("profile.passkeys.delete", {id})
);
}
}
</script>
{#if passkeys?.length > 0}
<h2 class="mb-4 font-bold">Your passkeys:</h2>
<div class=" divide-y">
{#each passkeys as passkey}
<div class="flex justify-between items-center py-2">
<div>
<p>Name: {passkey.name}</p>
<p>Last used at: {passkey.last_used_at || 'Never'}</p>
</div>
<button on:click={() => deletePasskey(passkey.id)}>Delete</button>
</div>
{/each}
</div>
{/if}
<button
class="btn"
on:click|preventDefault={addPassKey}
>
Add a passkey
</button>
First, you’ll need to implement the profile.passkeys.generate-options
route - this generates a JSON string of credentials contextual to the logged in user that are used to generate and store the passkeys.
Implement the generatePasskeyOptions
function in your controller:
public function generatePasskeyOptions()
{
$generatePassKeyOptionsAction = app(GeneratePasskeyRegisterOptionsAction::class);
return $generatePassKeyOptionsAction->execute(auth()->user());
}
This generates and returns the options to your front-end. Once this is returned to the front-end, you can pass this to your profile.passkeys.create
route:
router.post(
window.route("profile.passkeys.store"),
{
options: JSON.stringify(options),
passkey: JSON.stringify(startAuthenticationResponse)
}
);
It might look slightly strange that we’re calling JSON.stringify
here, but on the server side, there are a few methods that expect these values as JSON strings, rather than objects.
Now you can store the passkey:
$data = request()->validate([
'passkey' => 'required|json',
'options' => 'required|json',
]);
$user = auth()->user();
$storePasskeyAction = app(StorePasskeyAction::class);
try {
$storePasskeyAction->execute(
$user,
$data['passkey'],
$data['options'],
request()->getHost(),
['name' => Str::random(10)],
);
// Redirect back
return redirect()->back();
} catch (Throwable $e) {
throw ValidationException::withMessages([
'name' => __('passkeys::passkeys.error_something_went_wrong_generating_the_passkey'),
]);
}
And that’s it! the passkey should now appear in the passkeys
table, attached to your user.
Now you should add a route for deleting a passkey, so a user can remove one if it’s no longer in use:
public function deletePasskey(string $id)
{
auth()->user()->passkeys()->where('id', $id)->delete();
flash()->success('Passkey deleted successfully');
return redirect()->back();
}
Allowing people to log in using passkeys
In your Login component - add a new button to allow people to log in using passkeys.
<button on:click={withPassKey}>Authenticate with a passkey</button>
And make it call the following function:
async function withPassKey() {
const response = await fetch(window.route("passkeys.authentication_options"));
const options = await response.json();
const startAuthenticationResponse = await window.startAuthentication({ optionsJSON: options });
router.post(window.route("passkeys.login"), {
start_authentication_response: JSON.stringify(
startAuthenticationResponse
)
});
}
passkeys.login
is a route provided by the package that accepts a POST request with the start_authentication_response
parameter (also, again, a JSON string).
This should log your user in using their passkey, and redirect you to the URL set in the config/passkeys.php
file.
That’s it, you’re all set!
Thanks for reading
If you've enjoyed this article, have any questions, or would like to chat about it - please let me know over on ~Bluesky~.