NAME
Punk::Plugin::Passkey - passkey sign-in for Punk applications
SYNOPSIS
package MyApp;
use Punk;
session secret => secret('session_key');
host 'https://example.com';
auth model => 'User';
plugin 'Passkey';
# or, with everything named
plugin 'Passkey' => {
register_path => '/account/passkeys',
login_path => '/login/passkey',
model => 'Passkey',
user_verification => 'preferred',
has_other_factor => sub {
my ($c, $user_id) = @_;
$c->model('User')->get(id => $user_id)->{totp_enabled};
},
on_clone_signal => sub {
my ($cred, $stored, $asserted) = @_;
warn "passkey $cred->{credential_id} may be cloned";
},
};
DESCRIPTION
One line adds passkeys to an application that already has a session, a declared host and somewhere to store credentials. The protocol itself is Punk::Passkey, which this mounts routes over; everything about what is verified and why lives there.
What it needs, and what it refuses to start without
A session, because a ceremony issues a challenge in one request and checks it in the next, and the challenge cannot be kept anywhere the client can reach. And host, because the relying-party id and the origin check are configuration - taking them from the request would let a caller choose which site's credentials it is presenting, which is the check the entire scheme rests on.
Both are checked at to_app, and both croak naming what to add. Neither degrades into something partly working; they degrade into something that looks like it works.
THE ROUTES
GET {register_path} the management page
POST {register_path}/options creation options, as JSON
POST {register_path} register a credential
DELETE {register_path}/:id remove one
POST {login_path}/options request options, as JSON
POST {login_path} sign in
GET {asset_path} the browser helper, as JavaScript
The register_path routes answer 401 when nobody is signed in. Put them behind your own auth guard as well - under or the auth_guard keyword - so an unauthenticated request is refused by the same machinery that refuses every other account page, rather than by this plugin's own opinion.
The two login_path routes are unauthenticated by definition, and carry a per-address rate limit (30 a minute) when the application has "rate_limit" in Punk available - on top of anything you configure, not instead of it.
CSRF
The JSON endpoints are unsafe methods and ride the application's csrf keyword like any other. The browser helper sends the token in the X-CSRF-Token header, which is where the server-side check reads it for a non-form body.
Removing a credential
The last means of entry cannot be removed. A user with one passkey and nothing else configured who deletes it has locked themselves out, and finding that out is a support ticket at best.
Whether another factor exists is knowledge this plugin does not have, so it arrives as has_other_factor, a coderef receiving the context and the user id. Without one, "no other factor" is assumed - the safe direction. A user with two passkeys can always remove one.
Deletion is scoped to the signed-in user as well as the credential id. A credential id is an identifier, not a capability.
OPTIONS
- register_path, login_path, asset_path
-
Where the routes are mounted. Defaults
/account/passkeys,/login/passkeyand/punk-passkey.js. - model
-
The Punk::Model holding credentials, default
Passkey. The schema is the Sqitch project described in Punk::Passkey; passsqitch => 1to register it with Punk::Plugin::Sqitch.An application that declares
<AppClass>::Model::Passkey, or registers a model under this name, gets that one. An application that declares neither gets Punk::Model::Passkey, which is registered for it - so the plugin line and the deployed table are enough, with no model class to transcribe from the DDL. - user_verification, resident_key
-
Passed through to both ceremonies; both default to
preferred.requiredis how a deployment discovers, at the moment its users try to sign in, that an authenticator they already own cannot satisfy it. - user_id, user_name
-
Coderefs receiving the context, naming the signed-in user and what to show on their authenticator.
user_iddefaults to$c->auth_idwhen the auth battery is present. - credentials_for
-
A coderef receiving the context and a username, returning that user's credential ids for the
allowCredentialsflow. Without it, logins are usernameless: the authenticator offers whatever resident credential it holds.An unknown username produces an empty list and the ceremony fails the way a wrong one does - this endpoint does not report who has an account.
- has_other_factor
- on_clone_signal
-
A coderef receiving the row, the stored sign count and the asserted one, when a counter fails to increase. The login still succeeds - see "The sign count is a signal, not a gate" in Punk::Passkey.
- render
-
The management page: a coderef receiving the context and the user's credentials, or the name of a method on the context. Without it a plain built-in page is served, so the plugin works before anybody has written a template.
An application with a view engine writes
render => sub { $_[0]->render('account/passkeys', { rows => $_[1] }) }which needs no agreement between this distribution and yours about where templates live or which engine renders them.
THE BROWSER HELPER
GET {asset_path} serves a small vanilla JavaScript file - window.PunkPasskey - with register, login, supported and conditional. It has no dependencies, no build step and references no external origin, so an application with a Content-Security-Policy does not have to widen it to let sign-in work.
PunkPasskey.register('/account/passkeys');
PunkPasskey.login('/login/passkey');
conditional is the autofill flow: it feature-detects isConditionalMediationAvailable and does nothing where it is unsupported, so a browser without it simply shows your button.
<input name="username" autocomplete="username webauthn">
<script>PunkPasskey.conditional('/login/passkey',
function () { location = '/account' })</script>
RECOVERY
A passkey user still needs a way back in when the device is gone, and it should not be "email a link", which is forgeable and moves the whole account's security to a mailbox.
The recommended pairing is a second passkey as the primary recovery - another device, enrolled while the first still works - with Punk::TOTP's recovery codes behind it. This plugin deliberately does not mint a recovery scheme of its own: a second one to keep correct is a second one to get wrong, and the codes already exist.
Nothing here weakens an account that never enrols. Every route is additive beside password, TOTP and OAuth2 sign-in.
SEE ALSO
Punk::Passkey for the protocol and what it verifies, Punk, Punk::TOTP, Punk::OAuth2.
AUTHOR
LNATION <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION <email@lnation.org>.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)