NAME
Web::Authn::Cookbook - Implementing passkeys with Web::Authn
PURPOSE
This document is a start-to-finish recipe for a Relying Party (your website) using Web::Authn. It assumes you can run HTTPS (or http://localhost), store rows in a database, and keep a short-lived session. It does not assume Mojolicious, Dancer, Catalyst, or any particular ORM — the endpoint bodies are plain Perl you can paste behind whatever framework you use.
Runnable companions live in scripts/:
- scripts/schema.sql
-
SQLite / PostgreSQL-friendly tables.
- scripts/print-options.pl
-
Prints registration and authentication JSON with no server.
- scripts/webauthn.psgi
-
A tiny Plack app using the object-oriented API: four JSON routes + SQLite + cookie sessions.
- scripts/webauthn-using-class-functions.psgi
-
The same app, written with the exported class functions.
perldoc Web::Authn::Cookbook
plackup scripts/webauthn.psgi
THE MODEL IN ONE PAGE
A passkey is an asymmetric key pair. The authenticator (Touch ID, Windows Hello, a YubiKey, a phone via hybrid) holds the private key and never sends it. You store only:
credential_id— opaque bytes the authenticator chosepublic_key— a COSE_Key, stored as raw bytessign_count— monotonic counter used to detect cloned keysuser_handle— random bytes you put inuser.idat registration, stable for the life of the account
WebAuthn (and FIDO before it) calls each end-to-end dance a ceremony: browser, authenticator, and relying party exchanging a challenge and a signed response. There are two of them:
Registration ceremony — navigator.credentials.create() / attestation
Authentication ceremony — navigator.credentials.get() / assertion
W3C WebAuthn titles the Relying Party (i.e. the Perl app using Web::Authn) procedures that way: “Registering a New Credential” and “Verifying an Authentication Assertion” sit under some ceremonies.
There are two ceremonies:
REGISTER LOGIN
POST /webauthn/register/begin POST /webauthn/login/begin
| options JSON | options JSON
v v
navigator.credentials.create() navigator.credentials.get()
| |
POST /webauthn/register/complete POST /webauthn/login/complete
| verify + INSERT | verify + UPDATE sign_count
v v
row in credentials session cookie
BEFORE ANY CODE
Origin and rp_id
WebAuthn only works in a secure context.
Production page https://app.example.com
rp_id example.com # or app.example.com
expected_origin https://app.example.com
Local page http://localhost:5000
rp_id localhost
expected_origin http://localhost:5000
rp_id must be equal to the page's registrable domain, or a suffix of it. expected_origin is the full origin (scheme + host + port if non-default). Do not take either value from the client.
my %RP = (
rp_id => 'example.com',
rp_name => 'Example Co',
origin => 'https://app.example.com',
);
Dependencies
cpanm CryptX Bytes::Random::Secure
# for the sample PSGI app:
cpanm Plack Plack::Middleware::Session DBI DBD::SQLite JSON::PP
Web::Authn itself only needs CryptX and Bytes::Random::Secure.
Policy knobs
Start here for consumer passkeys:
authenticator_selection => {
resident_key => 'preferred', # 'required' = passkeys only
user_verification => 'preferred', # 'required' = passwordless
},
attestation => 'none',
Use attestation => 'direct' only if you will inspect vendor certificates and pass roots in pem_root_certs_bytes_by_fmt.
DATABASE SCHEMA
The sample schema is in scripts/schema.sql. Two tables plus an optional challenge table if you do not want to keep the challenge in the session store.
SQLite
CREATE TABLE users (
id INTEGER PRIMARY KEY
,email TEXT NOT NULL UNIQUE
,display_name TEXT NOT NULL
-- WebAuthn user handle: 16–64 random bytes, NOT the email.
,user_handle BLOB NOT NULL UNIQUE
);
CREATE TABLE credentials (
id INTEGER PRIMARY KEY
,user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
,credential_id BLOB NOT NULL UNIQUE
,public_key BLOB NOT NULL -- COSE_Key
,sign_count INTEGER NOT NULL DEFAULT 0
,aaguid TEXT
,fmt TEXT
,transports TEXT -- JSON array
,device_type TEXT -- singleDevice | multiDevice
,backed_up INTEGER NOT NULL DEFAULT 0
,created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX credentials_user ON credentials(user_id);
-- Optional if your session store cannot hold raw bytes:
CREATE TABLE webauthn_challenges (
id INTEGER PRIMARY KEY
,session_id TEXT NOT NULL
,purpose TEXT NOT NULL -- register | login
,challenge BLOB NOT NULL
,user_id INTEGER
,expires_at INTEGER NOT NULL
);
PostgreSQL
Same columns; use BYTEA instead of BLOB, BOOLEAN for backed_up, TIMESTAMPTZ for created_at.
What each column is
users.user_handle-
Created once with
$authn->generate_user_handle. Sent asuser.idat every registration. Some authenticators echo it back asuserHandleon login; you can use that to find the account during usernameless login. credentials.credential_id-
Primary lookup key on login. Compare as raw bytes, not as a locale-sensitive string.
credentials.public_key-
The exact
credential_public_keybytes returned by$authn->verify_registration_response. Do not re-encode them. credentials.sign_count-
Write
new_sign_countafter every successful login. If the authenticator reports a value that is not greater than the stored value (and either is non-zero), Web::Authn rejects the assertion.
SESSION
You need somewhere to keep the challenge between /begin and /complete. A signed cookie session, Redis, or the webauthn_challenges table all work. Rules:
16+ random bytes (the module default is 64).
One challenge, one use. Delete it after verify, success or failure.
Expire it (60–300 seconds). The
timeoutin the options is only a browser hint.
$session->{webauthn_challenge} = $opts->{challenge}; # raw bytes
$session->{webauthn_user_id} = $user->{id}; # register
FOUR ENDPOINTS
All four accept and return application/json. CSRF: these mutate state, so require your normal session cookie + SameSite, or a CSRF token. Do not skip authentication on register/begin — the user must already be identified (logged in with a password, or mid-signup after email verify).
The snippets below use a fictional $c request object with $c->session, $c->json, $c->render_json, and a fictional $db with dbh. Adapt the plumbing; keep the Web::Authn calls as written.
Two calling styles are equivalent. Pick one and stay with it.
Object-oriented (recommended). Build one Web::Authn per process (or per request) with the Relying Party identity. Methods return undef and set "error" in Web::Authn on failure; they die only if "fatal" in Web::Authn is true.
use Web::Authn;
my $authn = Web::Authn->new(
rp_id => $RP{rp_id},
rp_name => $RP{rp_name},
expected_origin => $RP{origin},
);
Class functions. The names exported by Web::Authn still work. They build a temporary object internally. Failures are reported the same way (undef + Web::Authn->error), unless you set fatal on that temporary object — so for eval / die you either enable fatal or check Web::Authn->error. The historical eval { ... } pattern still works if the called function throws (helpers such as Web::Authn::Parse still throw), but the public wrappers prefer error.
use Web::Authn qw(
generate_registration_options
verify_registration_response
generate_authentication_options
verify_authentication_response
options_to_json
bytes_to_base64url
base64url_to_bytes
generate_user_handle
);
POST /webauthn/register/begin
Call this when an already-identified user clicks “Add a passkey”.
sub register_begin
{
my $c = shift( @_ );
my $user = $c->current_user or return $c->status(401);
my $handle = $user->{user_handle};
if( !defined( $handle ) || !length( $handle ) )
{
$handle = $authn->generate_user_handle;
$db->update_user_handle( $user->{id}, $handle );
$user->{user_handle} = $handle;
}
my @exclude = map
{
{
type => 'public-key',
id => $_->{credential_id},
transports => $_->{transports},
}
} $db->credentials_for( $user->{id} );
my $opts = $authn->generate_registration_options(
user_name => $user->{email},
user_id => $handle,
user_display_name => $user->{display_name},
exclude_credentials => \@exclude,
authenticator_selection =>
{
resident_key => 'preferred',
user_verification => 'preferred',
},
attestation => 'none',
timeout => 60_000,
) || return( $c->status( 500, $authn->error->message ) );
$c->session->{webauthn_challenge} = $opts->{challenge};
$c->session->{webauthn_user_id} = $user->{id};
return( $c->render_json_raw( $authn->options_to_json( $opts ) ) );
}
The same with class functions:
my $handle = $user->{user_handle} || generate_user_handle();
my $opts = generate_registration_options(
rp_id => $RP{rp_id},
rp_name => $RP{rp_name},
user_name => $user->{email},
user_id => $handle,
user_display_name => $user->{display_name},
exclude_credentials => \@exclude,
authenticator_selection => {
resident_key => 'preferred',
user_verification => 'preferred',
},
attestation => 'none',
timeout => 60_000,
) || return( $c->status( 500, Web::Authn->error->message ) );
return( $c->render_json_raw( options_to_json( $opts ) ) );
exclude_credentials stops the same authenticator from being registered twice. options_to_json emits camelCase and base64url — that is the document @simplewebauthn/browser expects as optionsJSON.
POST /webauthn/register/complete
Body is the JSON produced by startRegistration() (or your own navigator.credentials.create wrapper).
sub register_complete
{
my $c = shift( @_ );
my $user = $c->current_user or return( $c->status(401) );
my $body = $c->json;
my $challenge = delete( $c->session->{webauthn_challenge} );
delete( $c->session->{webauthn_user_id} );
$challenge or return( $c->status( 400, 'no challenge in session' ) );
my $reg = $authn->verify_registration_response(
credential => $body,
expected_challenge => $challenge,
require_user_presence => 1,
require_user_verification => 0,
);
unless( $reg )
{
return( $c->status( 400, 'registration failed' ) );
}
$db->insert_credential(
user_id => $user->{id},
credential_id => $reg->{credential_id},
public_key => $reg->{credential_public_key},
sign_count => $reg->{sign_count},
aaguid => $reg->{aaguid},
fmt => $reg->{fmt},
device_type => $reg->{credential_device_type},
backed_up => $reg->{credential_backed_up} ? 1 : 0,
);
return( $c->render_json({ ok => JSON::PP::true }) );
}
The same with class functions:
my $reg = verify_registration_response(
credential => $body,
expected_challenge => $challenge,
expected_rp_id => $RP{rp_id},
expected_origin => $RP{origin},
require_user_presence => 1,
require_user_verification => 0,
) || return( $c->status( 400, Web::Authn->error->message ) );
Returned fields you should persist are listed in "verify_registration_response" in Web::Authn.
POST /webauthn/login/begin
Two shapes.
Username first — the user typed an email, you look them up:
sub login_begin
{
my $c = shift( @_ );
my $email = $c->json->{email} or
return( $c->status(400, 'email required') );
my $user = $db->user_by_email( $email ) or
return( $c->status( 404, 'unknown user' ) );
my @allow = map
{
{ type => 'public-key', id => $_->{credential_id} }
} $db->credentials_for( $user->{id} );
@allow or return( $c->status( 400, 'no passkeys on this account' ) );
my $opts = $authn->generate_authentication_options(
allow_credentials => \@allow,
user_verification => 'preferred',
) || return( $c->status( 500, $authn->error->message ) );
$c->session->{webauthn_challenge} = $opts->{challenge};
$c->session->{webauthn_user_id} = $user->{id};
return( $c->render_json_raw( $authn->options_to_json( $opts ) ) );
}
The same with class functions:
my $opts = generate_authentication_options(
rp_id => $RP{rp_id},
allow_credentials => \@allow,
user_verification => 'preferred',
) || return( $c->status( 500, Web::Authn->error->message ) );
return( $c->render_json_raw( options_to_json( $opts ) ) );
Usernameless / “Sign in with passkey” — omit allow_credentials so the authenticator picks a discoverable credential:
# OO
my $opts = $authn->generate_authentication_options(
user_verification => 'preferred',
) || return( $c->status( 500, $authn->error->message ) );
# class function
my $opts = generate_authentication_options(
rp_id => $RP{rp_id},
user_verification => 'preferred',
) || return( $c->status( 500, Web::Authn->error->message ) );
$c->session->{webauthn_challenge} = $opts->{challenge};
# no webauthn_user_id — you will find the user after verify
POST /webauthn/login/complete
sub login_complete
{
my $c = shift( @_ );
my $body = $c->json;
my $challenge = delete( $c->session->{webauthn_challenge} );
$challenge or return( $c->status( 400, 'no challenge in session' ) );
my $cred_id = $authn->base64url_to_bytes( $body->{id} );
$cred_id or return( $c->status( 400, 'bad credential id' ) );
my $row = $db->credential_by_id( $cred_id ) or
return( $c->status( 400, 'unknown credential' ) );
my $ok = $authn->verify_authentication_response(
credential => $body,
expected_challenge => $challenge,
credential_public_key => $row->{public_key},
credential_current_sign_count => $row->{sign_count},
require_user_verification => 0,
);
unless( $ok )
{
# cloned authenticator, wrong origin, replayed challenge…
return( $c->status( 401, 'authentication failed' ) );
}
$db->update_sign_count( $row->{id}, $ok->{new_sign_count} );
$c->establish_login( $row->{user_id} );
return( $c->render_json({ ok => JSON::PP::true }) );
}
The same with class functions:
my $cred_id = base64url_to_bytes( $body->{id} ) ||
return( $c->status( 400, 'bad credential id' ) );
my $ok = verify_authentication_response(
credential => $body,
expected_challenge => $challenge,
expected_rp_id => $RP{rp_id},
expected_origin => $RP{origin},
credential_public_key => $row->{public_key},
credential_current_sign_count => $row->{sign_count},
require_user_verification => 0,
) || return( $c->status( 401, Web::Authn->error->message ) );
On usernameless login, $ok->{user_handle} (if present) must match users.user_handle for $row->{user_id}; treat a mismatch as a hard failure.
FRONTEND
Binary fields cannot travel in JSON. This module and https://simplewebauthn.dev/ use the same convention: unpadded base64url on the wire.
npm install @simplewebauthn/browser
import {
startRegistration,
startAuthentication,
browserSupportsWebAuthn,
} from '@simplewebauthn/browser';
if( !browserSupportsWebAuthn() )
{
throw new Error('WebAuthn not available');
}
// Register
const begin = await fetch('/webauthn/register/begin', {
method: 'POST',
credentials: 'same-origin',
});
const optionsJSON = await begin.json();
const attResp = await startRegistration({ optionsJSON });
await fetch('/webauthn/register/complete', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attResp),
});
// Login (username first: send { email } on begin)
const lbegin = await fetch('/webauthn/login/begin', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const authJSON = await lbegin.json();
const assertResp = await startAuthentication({ optionsJSON: authJSON });
await fetch('/webauthn/login/complete', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(assertResp),
});
If you call navigator.credentials.create yourself you must decode base64url challenge, user.id, and descriptor id to Uint8Array on the way in, and encode rawId, clientDataJSON, attestationObject, authenticatorData, signature, userHandle on the way out.
DATA-ACCESS SNIPPETS
These match the schema above. Use placeholders; never interpolate binary ids into SQL.
sub insert_credential
{
my( $self, %row ) = @_;
$self->dbh->do(
q{INSERT INTO credentials
(user_id, credential_id, public_key, sign_count,
aaguid, fmt, device_type, backed_up)
VALUES (?,?,?,?,?,?,?,?)},
undef,
@row{qw(user_id credential_id public_key sign_count
aaguid fmt device_type backed_up)},
);
}
sub credential_by_id
{
my( $self, $id ) = @_;
return( $self->dbh->selectrow_hashref(
q{SELECT * FROM credentials WHERE credential_id = ?},
undef, $id,
) );
}
sub update_sign_count
{
my( $self, $pk, $count ) = @_;
$self->dbh->do(
q{UPDATE credentials SET sign_count = ? WHERE id = ?},
undef, $count, $pk,
);
}
ERRORS
Object-oriented:
my $ok = $authn->verify_authentication_response( %arg );
unless( $ok )
{
my $err = $authn->error;
if( $err->isa( 'Web::Authn::Exception::InvalidAuthentication' ) )
{
$log->info( "webauthn: $err" );
return( 401 );
}
die( $err );
}
$authn->fatal(1); # optional: methods die() with the exception
Class functions:
my $ok = verify_authentication_response( %arg );
unless( $ok )
{
my $err = Web::Authn->error;
if( $err->isa( 'Web::Authn::Exception::InvalidAuthentication' ) )
{
$log->info( "webauthn: $err" );
return( 401 );
}
die( $err );
}
Show the user a generic “That passkey could not be verified”. Log the exception server-side. Classes are documented in Web::Authn::Exception.
PRODUCT DETAILS
Adding a second passkey
Same register pair. exclude_credentials lists every existing credential_id so the platform will not silently replace the first key.
Removing a passkey
DELETE FROM credentials WHERE credential_id = ? AND user_id = ?. Require a fresh login or another passkey so an XSS session cannot strip the victim's only key.
Sign-count drop
If verify fails with a message about the signature counter, treat that credential as cloned. Disable it and ask the user to register a new one from a machine they control.
Recovery
Passwordless (user_verification => 'required', no password left) needs a recovery path that is not weaker than the passkey: a second passkey on another device, a hardware key, or in-person proof. An email magic link undoes most of the phishing resistance.
Attestation pinning
Only if you must restrict vendors. Ship the vendor root PEM yourself (Apple WebAuthn Root CA, Yubico, etc.). This distribution does not bundle them.
# OO
$authn->verify_registration_response(
credential => $body,
expected_challenge => $challenge,
pem_root_certs_bytes_by_fmt => {
apple => [ $apple_root_pem ],
packed => [ $yubico_root_pem ],
},
) || return( $c->status( 400, $authn->error->message ) );
# class function
verify_registration_response(
credential => $body,
expected_challenge => $challenge,
expected_rp_id => $RP{rp_id},
expected_origin => $RP{origin},
pem_root_certs_bytes_by_fmt => {
apple => [ $apple_root_pem ],
packed => [ $yubico_root_pem ],
},
) || return( $c->status( 400, Web::Authn->error->message ) );
LOCAL TESTING
# terminal 1
plackup -l http://localhost:5000 scripts/webauthn.psgi
Point a browser at http://localhost:5000. rp_id must be localhost and expected_origin http://localhost:5000. Chrome and Safari treat that origin as a secure context.
A platform authenticator (Touch ID, Windows Hello) or a cheap FIDO2 key is enough. In Chrome DevTools → Application → Passkeys you can inspect what was created.
CHECKLIST
rp_idandexpected_originare constants, not client input.Challenges live in the server session, one-time, short TTL.
user.idis random bytes, stored onusers.user_handle.credential_idandpublic_keystored as binary.sign_countupdated only after a successful verify.Register endpoints require an already-identified user.
Rate-limit begin and complete.
HTTPS in production.
AUTHOR
Jacques Deguest <jack@deguest.jp>
SEE ALSO
Web::Authn, Web::Authn::Exception, https://www.w3.org/TR/webauthn-3/, https://simplewebauthn.dev/, https://github.com/duo-labs/py_webauthn
COPYRIGHT & LICENSE
Copyright(c) 2026 DEGUEST Pte. Ltd.
All rights reserved.
This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself.