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/passkey and /punk-passkey.js.

model

The Punk::Model holding credentials, default Passkey. The schema is the Sqitch project described in Punk::Passkey; pass sqitch => 1 to register it with Punk::Plugin::Sqitch.

user_verification, resident_key

Passed through to both ceremonies; both default to preferred. required is 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_id defaults to $c->auth_id when the auth battery is present.

credentials_for

A coderef receiving the context and a username, returning that user's credential ids for the allowCredentials flow. 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

See "Removing a credential".

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)