NAME

Web::Authn - Server-side WebAuthn / passkeys

SYNOPSIS

use Web::Authn;

my $authn = Web::Authn->new(
    rp_id           => 'example.com',
    rp_name         => 'Example Co',
    expected_origin => 'https://example.com',
);

my $opts = $authn->generate_registration_options(
    user_name         => 'bob',
    user_display_name => 'Bob',
) || die( $authn->error );

my $json = $authn->options_to_json( $opts );   # send to the browser

my $reg = $authn->verify_registration_response(
    credential         => $browser_json,
    expected_challenge => $opts->{challenge},
) || die( $authn->error );
# persist $reg->{credential_id}, $reg->{credential_public_key}, $reg->{sign_count}

my $assert_opts = $authn->generate_authentication_options(
    allow_credentials => [
        { type => 'public-key', id => $reg->{credential_id} },
    ],
) || die( $authn->error );

my $ok = $authn->verify_authentication_response(
    credential                    => $browser_json,
    expected_challenge            => $assert_opts->{challenge},
    credential_public_key         => $reg->{credential_public_key},
    credential_current_sign_count => $reg->{sign_count},
) || die( $authn->error );

$authn->fatal(1);   # subsequent errors die() as exception objects

Functional wrappers still exist (they build a temporary object):

use Web::Authn qw( generate_registration_options options_to_json );

VERSION

v0.2.2

DESCRIPTION

Web::Authn is a Perl port of Duo Labs' py_webauthn. It implements the Relying Party operations of the W3C WebAuthn Level 2/3 specifications: option generation and cryptographic verification of registration and authentication ceremonies.

Methods return, undef in scalar context or an empty list in list context, on failure and store a Web::Authn::Exception on the object and in Web::Authn->error. They die only when "fatal" is true.

Functional wrappers remain exported on request and dispatch to a temporary object.

See Web::Authn::Cookbook for integration.

It is not a user/session framework. You store challenges, credential IDs, COSE public keys and signature counters yourself.

The usual integration is four JSON endpoints: register/begin, register/complete, login/begin, login/complete. See the distribution README.md for a full walk-through, storage schema, and frontend notes.

EXPORTS

Nothing is exported by default. Request what you need:

use Web::Authn qw(
    generate_registration_options
    verify_registration_response
    generate_authentication_options
    verify_authentication_response
    options_to_json
    options_to_json_dict
    base64url_to_bytes
    bytes_to_base64url
    generate_challenge
    generate_user_handle
);

METHODS

Named parameters may be passed either as a flat list or as a single hash reference. Both of the following are equivalent:

$authn->generate_registration_options( rp_id => $id, user_name => $name );
$authn->generate_registration_options({ rp_id => $id, user_name => $name });

An odd number of list elements throws Web::Authn::Exception.

String arguments may be plain Perl scalars or blessed objects that overload stringification (overload::Method( $obj, '""' )), for example Module::Generic::Scalar. They are converted with $obj . '' before use.

Array arguments (such as origins, algorithm lists, credential descriptors) may be a native array reference or a blessed array whose reftype is ARRAY, for example Module::Generic::Array.

new

my $authn = Web::Authn->new(
    rp_id             => 'example.com',
    rp_name           => 'Example Co',
    expected_origin   => 'https://example.com',
    expected_rp_id    => 'example.com',   # defaults to rp_id
    attestation       => 'none',
    timeout           => 60_000,
    user_verification => 'preferred',
    fatal             => 0,
);

my $authn = Web::Authn->new({
    rp_id           => 'example.com',
    rp_name         => 'Example Co',
    expected_origin => 'https://example.com',
}) || die( Web::Authn->error );

my $authn = Web::Authn->new(
    rp_id   => 'example.com',
    rp_name => 'Angels, Inc',
    origins => [ 'https://www.angels-inc.com' ],
    timeout => 120_000,
    debug   => 1,
) || die( Web::Authn->error );

Stores Relying Party defaults used by later methods. If new is called on an existing instance, a failure stores the error on that instance so the caller can write $authn->new( %args ) || die( $authn->error ).

Supported options are:

attestation

Optional. String. One of none, indirect, direct, or enterprise. Default none. Conveyance preference copied onto options produced by "generate_registration_options".

debug

Optional. Boolean. Default false. Stored on the object for the caller; it does not change WebAuthn verification.

expected_origin

Optional at construction, required later by "verify_registration_response" and "verify_authentication_response" unless passed there. String (a full origin such as https://example.com) or array of such strings. Compared to clientDataJSON.origin.

expected_origins

Optional. Alias of expected_origin. Same type.

expected_rp_id

Optional. String. Effective RP ID used during verification. Defaults to rp_id.

fatal

Optional. Boolean. Default false. When true, "error" throws the exception instead of returning undef. See "fatal".

origin

Optional. Alias of expected_origin. Same type.

origins

Optional. Alias of expected_origin. Same type. Useful when the application config key is plural.

rp_id

Optional at construction, required (here or on the call) by "generate_registration_options" and "generate_authentication_options". String. Registrable domain of the Relying Party, for example example.com. Must match the RP ID hash inside authenticator data.

rp_name

Optional at construction, required (here or on the call) by "generate_registration_options". String. Human-readable Relying Party name shown by the authenticator UI.

timeout

Optional. Integer. Milliseconds. Default 60000. Hint copied onto generated options.

user_verification

Optional. String. One of required, preferred, or discouraged. Default preferred. Copied onto generated options.

base64url_to_bytes

my $raw = $authn->base64url_to_bytes( $credential->{id} ) ||
    die( $authn->error );

Decodes unpadded base64url to raw bytes. The only argument is the string to decode (characters A-Za-z0-9_-). You may also pass an object that overloads stringification, such as Module::Generic::Scalar.

bytes_to_base64url

my $id = $authn->bytes_to_base64url( $reg->{credential_id} );

Encodes raw bytes as unpadded base64url. The only argument is the byte string to encode. You may also pass an object that overloads stringification.

error

my $authn = Web::Authn->new( %bad_args );
if( !defined( $authn ) )
{
    my $err = Web::Authn->error;
    warn "Error: $err";
}

my $err = $authn->error;
warn $err->message, ' at ', $err->file, ' line ', $err->line;

$authn->error( 'something went wrong' );
$authn->error({ class => 'Web::Authn::Exception::InvalidRegistration', message => 'bad' });

Instance and class method. When called with a message, constructs a Web::Authn::Exception object, stores it internally, and either warns (if fatal mode is off) or dies (if fatal mode is on). Returns undef in scalar context, an empty list in list context.

When called without arguments, returns the most recent error object (or undef if no error has occurred).

class

This key is optional. It is a string naming the exception class to bless into, and it defaults to Web::Authn::Exception.

message

This key is optional when you pass a hash (the sole positional string is treated as the message). It is the human-readable error text.

skip_frames

This key is optional. It is an integer: how many extra caller frames to skip when recording file and line.

fatal

$authn->fatal(1); # Enable fatal exceptions
$authn->fatal(0); # Disable fatal exceptions
my $bool = $authn->fatal;

Sets or get the boolean value, whether to die upon exception, or not. If set to true, then instead of setting an exception object, this module will die with an exception object. You can catch the exception object then after using try. For example:

use v.5.34; # to be able to use try-catch blocks in perl
use experimental 'try';
no warnings 'experimental';
try
{
    my $authn = Web::Authn->new( fatal => 1 );
    # Forgot the 'rp_id':
    my $bad = $authn->generate_registration_options( rp_id => '', rp_name => 'x', user_name => 'y' );
}
catch( $e )
{
    say "Error occurred: ", $e->message;
    # Error occurred: No value for width was provided.
}

Called with a boolean, this method sets whether subsequent "error" calls throw. Called without arguments, it returns the current flag.

generate_registration_options

my $opts = $authn->generate_registration_options(
    user_name               => 'bob@example.com',
    user_id                 => $handle,          # raw bytes; optional
    user_display_name       => 'Bob',
    challenge               => $bytes,           # optional
    timeout                 => 60_000,
    attestation             => 'none',
    exclude_credentials     => [ { type => 'public-key', id => $cid } ],
    supported_pub_key_algs  => [ -8, -7, -257 ],
    authenticator_selection => {
        resident_key      => 'preferred',
        user_verification => 'preferred',
    },
    hints => [ 'client-device' ],
) || die( $authn->error );

my $opts = $authn->generate_registration_options({
    user_name => 'bob@example.com',
    user_id   => $handle,
}) || die( $authn->error );

Returns a Perl hash describing PublicKeyCredentialCreationOptions.

Pass it through "options_to_json" before sending it to the browser.

An empty rp_id, rp_name, or user_name is rejected via "error".

attestation

This argument is optional. It is a string, one of none, indirect, direct, or enterprise. It defaults to the value stored by "new", or to none. It is the conveyance preference placed on the creation options.

authenticator_selection

This argument is optional. It is a hash. Recognised keys are authenticator_attachment (the string platform or cross-platform), resident_key (discouraged, preferred, or required), and user_verification (required, preferred, or discouraged). If resident_key is required, require_resident_key is also set to true on the options.

challenge

This argument is optional. It is a raw byte string: the cryptographic challenge issued to the authenticator. It defaults to 64 CSPRNG bytes from "generate_challenge". Store this value until you call "verify_registration_response".

exclude_credentials

This argument is optional. It is an array of descriptor hashes of the form { type = 'public-key', id => $raw_bytes, transports => [ ... ] }>, where id is raw bytes. It prevents re-registration of authenticators the account already has.

hints

This argument is optional. It is an array of strings: WebAuthn Level 3 UI hints, one or more of security-key, client-device, and hybrid.

rp_id

This argument is required unless it was already set on the object by "new". It is a string: the registrable domain of the Relying Party.

rp_name

This argument is required unless it was already set on the object by "new". It is a string: the human-readable Relying Party name.

supported_pub_key_algs

This argument is optional. It is an array of integers (COSE algorithm identifiers). It defaults to EdDSA (-8), ES256 (-7), and RS256 (-257). Constants live in Web::Authn::COSE.

timeout

This argument is optional. It is an integer number of milliseconds. It defaults to the value stored by "new", or to 60000. It is a client-side hint only.

user_display_name

This argument is optional. It is a string shown in the OS passkey UI. It defaults to user_name.

user_id

This argument is optional. It is raw bytes identifying the account to the authenticator, and it must not be an email or other personal information. It defaults to 64 random bytes from "generate_user_handle". Keep this value stable for the life of the account.

user_name

This argument is required. It is a string: the account identifier shown to the user, often an email or login.

generate_authentication_options

my $opts = $authn->generate_authentication_options(
    allow_credentials => [ { type => 'public-key', id => $cid } ],
    user_verification => 'preferred',
    timeout           => 60_000,
    challenge         => $bytes,      # optional
) || die( $authn->error );

# usernameless / discoverable:
my $opts = $authn->generate_authentication_options;

my $opts = $authn->generate_authentication_options({
    allow_credentials => [ { type => 'public-key', id => $cid } ],
}) || die( $authn->error );

Returns PublicKeyCredentialRequestOptions. Pass it through "options_to_json" before sending it to the browser. An empty rp_id is rejected via "error".

allow_credentials

This argument is optional. It is an array of descriptor hashes of the form { type = 'public-key', id => $raw_bytes, transports => [ ... ] }>, where id is raw bytes. Omit it or pass [] for usernameless (discoverable) credentials.

challenge

This argument is optional. It is a raw byte string: the cryptographic challenge issued to the authenticator. It defaults to 64 CSPRNG bytes from "generate_challenge". Store this value until you call "verify_authentication_response".

rp_id

This argument is required unless it was already set on the object by "new". It is a string: the registrable domain of the Relying Party.

timeout

This argument is optional. It is an integer number of milliseconds. It defaults to the value stored by "new", or to 60000. It is a client-side hint only.

user_verification

This argument is optional. It is a string, one of required, preferred, or discouraged. It defaults to the value stored by "new", or to preferred.

generate_challenge

my $chal = $authn->generate_challenge;      # 64 bytes
my $chal = $authn->generate_challenge(16);

Returns raw challenge bytes from Bytes::Random::Secure. You may pass an optional integer length; it defaults to 64.

generate_user_handle

my $handle = $authn->generate_user_handle;  # 64 random bytes

Returns 64 raw random bytes suitable as a WebAuthn user.id. No arguments.

options_to_json

print $authn->options_to_json( $opts );

Serialises registration or authentication options to a JSON string suitable for Content-Type: application/json. Byte fields (challenge, user.id, descriptor id) become unpadded base64url. Keys are camelCase as the WebAuthn spec uses them. The only argument is the hash returned by "generate_registration_options" or "generate_authentication_options".

options_to_json_dict

my $href = $authn->options_to_json_dict( $opts );

Same conversion as "options_to_json", but returns a hash instead of a JSON string. The only argument is the same options hash.

pass_error

sub my_method
{
    my $self = shift( @_ );
    my $authn = Web::Authn->new( %bad_args ) ||
        return( $self->pass_error( Web::Authn->error ) );
    ...
}

Propagates the error stored in another object (or the class-level error) into the current object's error slot, without constructing a new exception. Used internally when a lower-level call fails and the caller wants to surface the same error to its own caller.

verify_registration_response

my $reg = $authn->verify_registration_response(
    credential                    => $browser_json,
    expected_challenge            => $opts->{challenge},
    expected_rp_id                => 'example.com',   # default: new()
    expected_origin               => 'https://example.com',
    require_user_presence         => 1,
    require_user_verification     => 0,
    supported_pub_key_algs        => [ -8, -7, -257 ],
    pem_root_certs_bytes_by_fmt   => { packed => [ $pem ] },
) || die( $authn->error );

my $reg = $authn->verify_registration_response({
    credential         => $browser_json,
    expected_challenge => $opts->{challenge},
    expected_rp_id     => 'example.com',
    expected_origin    => 'https://example.com',
}) || die( $authn->error );

Implements WebAuthn §7.1.

On success returns a hash of fields to persist (see below).

On failure stores Web::Authn::Exception::InvalidRegistration, and returns undef in scalar context or an empty list in list context, unless "fatal" is true.

Supported options are:

credential

This argument is required. It is either a JSON string, or a hash in the SimpleWebAuthn / py_webauthn shape (id, rawId, type, response.clientDataJSON, response.attestationObject as unpadded base64url). This is the value returned by navigator.credentials.create().

expected_challenge

This argument is required. It is a raw byte string: the challenge previously issued by "generate_registration_options". It must match clientDataJSON.challenge.

expected_origin

This argument is required unless it was already set on the object by "new". It is either a string containing a full origin such as https://example.com, or an array of such strings. It must match clientDataJSON.origin.

expected_origins

This argument is optional. It is an alias of expected_origin and accepts the same values.

expected_rp_id

This argument is required unless it was already set on the object by "new" (as expected_rp_id or rp_id). It is a string: the registrable domain. Its SHA-256 must equal the RP ID hash in authenticator data.

origin

This argument is optional. It is an alias of expected_origin and accepts the same values.

origins

This argument is optional. It is an alias of expected_origin and accepts the same values.

pem_root_certs_bytes_by_fmt

This argument is optional. It is a hash mapping an attestation format name (for example packed or fido-u2f) to an array of PEM or DER root certificates (strings or raw bytes). It is used when verifying packed or FIDO U2F statements that carry an x5c. It is unused for fmt=none.

require_user_presence

This argument is optional. It is a boolean and defaults to true. When it is true, authenticator data must have the User Present (UP) flag set.

require_user_verification

This argument is optional. It is a boolean and defaults to false. When it is true, authenticator data must have the User Verified (UV) flag set.

supported_pub_key_algs

This argument is optional. It is an array of integers (COSE algorithm identifiers). It restricts which credential public-key algorithms are accepted. The default includes the Web::Authn::COSE defaults plus ES384, ES512, RS384, RS512, PS256, PS384 and PS512.

Returns a hash:

credential_id            raw bytes
credential_public_key    COSE_Key bytes (store as-is)
sign_count               integer
aaguid                   UUID string
fmt                      attestation format
credential_type          "public-key"
user_verified            0/1
attestation_object       raw bytes
credential_device_type   "singleDevice" | "multiDevice"
credential_backed_up     0/1

verify_authentication_response

my $ok = $authn->verify_authentication_response(
    credential                    => $browser_json,
    expected_challenge            => $opts->{challenge},
    expected_rp_id                => 'example.com',
    expected_origin               => 'https://example.com',
    credential_public_key         => $row->{public_key},
    credential_current_sign_count => $row->{sign_count},
    require_user_verification     => 0,
) || die( $authn->error );

my $ok = $authn->verify_authentication_response({
    credential                    => $browser_json,
    expected_challenge            => $opts->{challenge},
    credential_public_key         => $row->{public_key},
    credential_current_sign_count => $row->{sign_count},
}) || die( $authn->error );

Implements WebAuthn §7.2. On success returns a hash (see below). On failure returns undef and stores Web::Authn::Exception::InvalidAuthentication unless "fatal" is true.

The signature counter must strictly increase when either the stored or reported count is non-zero; otherwise the response is rejected as a possible cloned authenticator.

credential

This argument is required. It is either a JSON string, or a hash in the SimpleWebAuthn / py_webauthn shape (id, rawId, type, response.clientDataJSON, response.authenticatorData, response.signature as unpadded base64url). This is the value returned by navigator.credentials.get().

credential_current_sign_count

This argument is required. It is an integer: the sign_count last persisted for this credential. It is compared to the counter in authenticator data.

credential_public_key

This argument is required. It is raw bytes: the credential_public_key COSE_Key blob returned by "verify_registration_response" and stored as-is.

expected_challenge

This argument is required. It is a raw byte string: the challenge previously issued by "generate_authentication_options". It must match clientDataJSON.challenge.

expected_origin

This argument is required unless it was already set on the object by "new". It is either a string containing a full origin such as https://example.com, or an array of such strings. It must match clientDataJSON.origin.

expected_origins

This argument is optional. It is an alias of expected_origin and accepts the same values.

expected_rp_id

This argument is required unless it was already set on the object by "new" (as expected_rp_id or rp_id). It is a string: the registrable domain. Its SHA-256 must equal the RP ID hash in authenticator data.

origin

This argument is optional. It is an alias of expected_origin and accepts the same values.

origins

This argument is optional. It is an alias of expected_origin and accepts the same values.

require_user_verification

This argument is optional. It is a boolean and defaults to false. When it is true, authenticator data must have the User Verified (UV) flag set.

Returns:

credential_id            raw bytes
new_sign_count           persist this
credential_device_type
credential_backed_up
user_verified
user_handle              bytes or undef

error

my $authn = Web::Authn->new( %bad_args );
if( !defined( $authn ) )
{
    my $err = Web::Authn->error;
    warn "Error: $err";
}

Instance and class method. When called with a message, constructs a Web::Authn::Exception object, stores it internally, and either warns (if fatal mode is off) or dies (if fatal mode is on). Returns undef in scalar context, an empty list in list context.

When called without arguments, returns the most recent error object (or undef if no error has occurred).

fatal

$authn->fatal(1); # Enable fatal exceptions
$authn->fatal(0); # Disable fatal exceptions
my $bool = $authn->fatal;

Sets or get the boolean value, whether to die upon exception, or not. If set to true, then instead of setting an exception object, this module will die with an exception object. You can catch the exception object then after using try. For example:

use v.5.34; # to be able to use try-catch blocks in perl
use experimental 'try';
no warnings 'experimental';
try
{
    my $authn = Web::Authn->new( fatal => 1 );
    # Forgot the 'rp_id':
    my $bad = $authn->generate_registration_options( rp_id => '', rp_name => 'x', user_name => 'y' );
}
catch( $e )
{
    say "Error occurred: ", $e->message;
    # Error occurred: No value for width was provided.
}

pass_error

sub my_method
{
    my $self = shift( @_ );
    my $authn = Web::Authn->new( %bad_args ) ||
        return( $self->pass_error( Web::Authn->error ) );
    ...
}

Propagates the error stored in another object (or the class-level error) into the current object's error slot, without constructing a new exception. Used internally when a lower-level call fails and the caller wants to surface the same error to its own caller.

ATTESTATION FORMATS

"parse_attestation_object" in Web::Authn::Parse decodes the CBOR attestationObject into fmt, authData and attStmt. "verify" in Web::Authn::Attestation then dispatches on the fmt argument (none, packed, fido-u2f, apple, tpm, android-safetynet, android-key). That package has no attestationObject field; fmt is a named parameter.

none

Statement must be empty. Typical for consumer passkeys.

packed

Signature over authData || SHA-256(clientDataJSON). Self-attestation uses the credential public key; x5c uses the leaf certificate (optional root pinning).

fido-u2f

U2F verificationData, P-256, AAGUID all zeros.

apple

x5c, nonce in OID 1.2.840.113635.100.8.2, subject public key matches the credential key. Pass Apple's WebAuthn root via pem_root_certs_bytes_by_fmt.

tpm

x5c, signature over certInfo, extraData contains a hash of attToBeSigned.

android-safetynet

JWT signature, ctsProfileMatch, nonce.

android-key

x5c plus packed-style signature.

Certificate chains against RP-supplied roots are validated in process (CryptX verifies each certificate signature). There is no openssl(1) subprocess. If you pass no roots, pinning is skipped (same as py_webauthn).

COSE ALGORITHMS

Verified with CryptX: ES256/384/512, Ed25519 (EdDSA), RS256/384/512, PS256/384/512.

ML-DSA (-48/-49/-50) keys can be decoded; verification is not implemented.

EXCEPTIONS

See Web::Authn::Exception. Generation of empty rp_id etc. uses "croak" in Carp instead.

DEPENDENCIES

CryptX, Bytes::Random::Secure, and core Digest::SHA, JSON::PP, MIME::Base64.

REFERENCES

Normative documents this module is written against. Dated REC links are pinned; undated /TR/webauthn-3/ always resolves to the latest published Level 3.

WebAuthn (W3C) — browser API and RP procedures

Level 3 Recommendation (25 August 2026) — current

https://www.w3.org/TR/webauthn-3/

Pinned: https://www.w3.org/TR/2026/REC-webauthn-3-20260825/

RP registration procedure: https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential (§7.1)

RP authentication procedure: https://www.w3.org/TR/webauthn-3/#sctn-verifying-assertion (§7.2)

Attestation statement formats: https://www.w3.org/TR/webauthn-3/#sctn-defined-attestation-formats (§8)

Editor's Draft (next level): https://w3c.github.io/webauthn/

Working Group repo: https://github.com/w3c/webauthn

Level 2 Recommendation (8 April 2021)

https://www.w3.org/TR/webauthn-2/

Pinned: https://www.w3.org/TR/2021/REC-webauthn-2-20210408/

Level 1 Recommendation (4 March 2019)

https://www.w3.org/TR/webauthn-1/

Pinned: https://www.w3.org/TR/2019/REC-webauthn-1-20190304/

verify_registration_response implements §7.1. verify_authentication_response implements §7.2.

CTAP / FIDO2 — authenticator protocol

WebAuthn is the web API. CTAP is how the platform talks to the authenticator (USB, NFC, BLE, hybrid). Together they are FIDO2.

CTAP 2.2 Proposed Standard (28 February 2025)

https://fidoalliance.org/specs/fido-v2.2-ps-20250228/fido-client-to-authenticator-protocol-v2.2-ps-20250228.html

CTAP 2.1 Proposed Standard

https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-20210615.html

CTAP 2.0 (27 February 2018)

https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-client-to-authenticator-protocol-v2.0-id-20180227.html

FIDO Alliance specifications index

https://fidoalliance.org/specifications/

ITU-T X.1278 (CTAP aligned)

https://www.itu.int/rec/T-REC-X.1278

Encoding and algorithms

RFC 8949 — CBOR

https://www.rfc-editor.org/rfc/rfc8949.html

RFC 8610 — CDDL

https://www.rfc-editor.org/rfc/rfc8610.html

RFC 9052 / 9053 — COSE

https://www.rfc-editor.org/rfc/rfc9052.html

https://www.rfc-editor.org/rfc/rfc9053.html

RFC 8812 — COSE/JOSE registrations used by WebAuthn (RS256 etc.)

https://www.rfc-editor.org/rfc/rfc8812.html

IANA COSE Algorithms registry

https://www.iana.org/assignments/cose/cose.xhtml#algorithms

IANA COSE Key Types / Elliptic Curves

https://www.iana.org/assignments/cose/cose.xhtml

RFC 5280 — X.509 (attestation x5c)

https://www.rfc-editor.org/rfc/rfc5280.html

RFC 4648 §5 — base64url

https://www.rfc-editor.org/rfc/rfc4648.html#section-5

FIDO Alliance — passkeys overview

https://fidoalliance.org/passkeys/

py_webauthn (API this port mirrors)

https://github.com/duo-labs/py_webauthn

THREAD & PROCESS SAFETY

Web::Authn is designed to be fully thread-safe and process-safe, ensuring data integrity across Perl ithreads and mod_perl’s threaded Multi-Processing Modules (MPMs) such as Worker or Event, provided each thread constructs and uses its own objects. Do not pass a Web::Authn instance, a CryptX key object, or a Bytes::Random::Secure generator across threads->create.

Perl ithreads clone the interpreter. Package variables such as $Web::Authn::ERROR and $JSON_CLASS are copied, then independent. They are not :shared. That is the intended model: each thread has its own last-error slot.

What is not safe to share:

  • A blessed Web::Authn object created in another thread. Create it inside the worker (Web::Authn->new( ... ) after threads->create, or in a prefork child after fork).

  • CryptX public-key objects returned by Web::Authn::Crypto. Persist COSE public-key bytes in the database and rebuild the key in the thread that verifies.

  • A cached Bytes::Random::Secure object. "_random" in Web::Authn::Parse therefore constructs a new generator for every challenge or user handle. Do not add a process-wide singleton.

JSON backends (Cpanel::JSON::XS, JSON::XS, JSON::PP) are used by creating a new coder per call ($JSON_CLASS->new). Do not stash a coder object in a package global.

Typical prefork servers (Starman, Hypnotoad, Apache prefork + mod_perl) start workers after compile. Each worker is a separate process, not an ithread; no extra care is required beyond not sharing memory on purpose.

Typical ithreads pattern:

use threads;
my $thr = threads->create(sub
{
    my $authn = Web::Authn->new(
        rp_id           => 'example.com',
        expected_origin => 'https://example.com',
    );
    my $opts = $authn->generate_registration_options(
        user_name => 'bob',
    ) || die( $authn->error );
    return( $opts->{challenge} );
});
my $challenge = $thr->join;

fork() without exec: avoid calling generate/verify in the parent and the child with the same in-memory CryptX object. Rebuild from stored bytes in the child.

CREDITS

Duo Labs / Matthew Miller for his work on the Python's py_webauthn.

AUTHOR

Jacques Deguest <jack@deguest.jp>

SEE ALSO

Web::Authn::Cookbook, Web::Authn::Parse, Web::Authn::Attestation, Web::Authn::Crypto, Web::Authn::COSE, Web::Authn::CBOR, Web::Authn::Exception, Authen::WebAuthn

https://github.com/duo-labs/py_webauthn, https://www.w3.org/TR/webauthn-3/, Authen::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.