WebAuthn / passkey ceremony codec — thin wrappers overpy_webauthn.
Requires the optionalwebauthndependency::
pip install chirp[passkeys]
Chirp owns the verb (the ceremony + the session-bound challenge lifecycle);
the app owns the row (credential persistence), exactly as
hash_password() / verify_password()
own the hashing verb but not the user table, and as theUserprotocol means
the app brings its own user model. Passkeys are one authenticator among several;
they slot beside passwords, they do not replace the identity core.
Each ceremony is a begin → finish pair.begin_*mints a challenge,
stashes it single-use in the session, and returns an optionsdictready for
JSONResponse. finish_*pops the challenge,
verifies the authenticator's response, and returns a plain DTO the app
persists::
from chirp.security.passkeys import (
PasskeyConfig, begin_registration, finish_registration,
begin_authentication, finish_authentication,
)
PK = PasskeyConfig(rp_id="example.com", rp_name="Example",
origin="https://example.com")
# registration (enroll a credential for an already-identified user)
@app.route("/auth/passkey/register/begin", methods=["POST"])
@login_required
async def register_begin(request):
u = current_user()
return JSONResponse.from_value(
begin_registration(user_id=u.id.encode(), user_name=u.email, config=PK)
)
@app.route("/auth/passkey/register/finish", methods=["POST"])
@login_required
async def register_finish(request):
cred = finish_registration(credential=await request.json(), config=PK)
await store.save_credential(cred, user_id=current_user().id) # app owns the row
return FormAction("/settings/passkeys")
# authentication (prove possession → the handler then calls login(user))
@app.route("/auth/passkey/login/finish", methods=["POST"])
async def login_finish(request):
body = await request.json()
stored = await store.load_credential(base64url_to_bytes(body["id"]))
if stored is None:
return ValidationError("login.html", "passkey_form",
errors={"passkey": "Unknown credential"})
verified = finish_authentication(credential=body, stored=stored, config=PK)
await store.update_sign_count(stored.credential_id, verified.new_sign_count)
login(await load_user(stored.user_id)) # ← single identity-termination point
return FormAction("/dashboard")
Ordering is enforced by the verb, not by app discipline.finish_*
consumes (pops) the challenge before it verifies, and it never calls
login(). The handler calls login() only after finish_*returns — so
login() → regenerate_session() (which session.clear()s the dict) can
never wipe a not-yet-consumed challenge. The challenge is single-use (popped on
both success and failure) and carries an embedded expiry (the session layer has
no per-key TTL of its own).
Verification is fail-closed:py_webauthn's verify_*raise on any
mismatch (they never return a falsy result). The verbs catchWebAuthnException
broadly at the boundary and re-raise a genericPasskeyVerificationError,
so the route never leaks which specific check failed to the client.
security.passkeys
| Name | Type | Default | Description |
|---|---|---|---|
type
|
|
— | |
qualified_name
|
|
— | |
element_type
|
|
— | |
description
|
|
— | |
source_file
|
|
— | |
line_number
|
|
— | |
is_autodoc
|
|
— | |
autodoc_element
|
|
— | |
_autodoc_template
|
|
— | |
_autodoc_url_path
|
|
— | |
_autodoc_page_type
|
|
— | |
title
|
|
— | |
doc_content_hash
|
|
— |
Symbols on this page
Base for passkey ceremony failures.
No usable challenge was found for a finish ceremony.
The challenge was missing, expired, or already consumed (replay). Surface a generic "please try again"…
An authenticator response failed verification.
Wraps anywebauthn.helpers.exceptions.WebAuthnExceptionso the route handler catches one type and surfaces a generic auth failure. The original exception…
ReturnTrue if the optional webauthnpackage is importable.
Used by thepasskeys startup contract check (rules_passkeys) — the same find-spec probe…
Importwebauthnlazily, or fail loud with an actionable message.
Imported inside the verb functions (never at module top level) so
import chirp/ …
Relying-party configuration for the WebAuthn ceremonies.
Static, serializable data — constructible withoutwebauthninstalled (so the startup contract can inspect it). Misconfiguration fails loud…
The shape a stored credential must expose for authentication.
The framework defines the shape; the app owns persistence (a row, an ORM model, a…
The verified result of a registration ceremony, for the app to persist.
Binduser_idyourself when you store it (registration enrolls a credential for…
The verified result of an authentication ceremony.
Persistnew_sign_countagainst the stored credential before calling
login(). sign_count_regressedis the clone-detection signal— the…
Encode bytes as unpadded base64url (JSON-safe for the session serializer).
Decode unpadded base64url back to bytes.
Store the challenge single-use in the session with an embedded deadline.
RaisesLookupError if no SessionMiddlewareis active — passkeys require the secure-by-default stack,…
Pop the stored challenge, returning it only if present and unexpired.
Always removes the key (single-use, even on expiry/corruption) to defeat
replay. ReturnsNone…
Best-effort pull of authenticator transports from the client credential.
Transports live in the client response (response.transports), not in the verified registration result,…
Mint registration options, stash the challenge, return options JSON.
Pop the challenge, verify the attestation, return a credential to persist.
Mint authentication options, stash the challenge, return options JSON.
Pop the challenge, verify the assertion against the stored credential.
Does not calllogin() — the handler persists new_sign_countand
then callslogin(user)(the…
PasskeyError
class
Base for passkey ceremony failures.
PasskeyChallengeError
class
No usable challenge was found for a finish ceremony.
The challenge was missing, expired, or already consumed (replay). Surface a generic "please try again" to the client — never the specific cause.
PasskeyVerificationError
class
An authenticator response failed verification.
Wraps anywebauthn.helpers.exceptions.WebAuthnExceptionso the route
handler catches one type and surfaces a generic auth failure. The original
exception is intentionally suppressed (from None) so the specific failed
check does not leak to the caller / client.
_has_webauthn
function
def _has_webauthn() -> bool
ReturnTrue if the optional webauthnpackage is importable.
Used by thepasskeys startup contract check (rules_passkeys) — the
same find-spec probe the runtime uses, so the check and the runtime agree.
No parameters.
_require_webauthn
function
def _require_webauthn() -> ModuleType
Importwebauthnlazily, or fail loud with an actionable message.
Imported inside the verb functions (never at module top level) so
import chirp / import chirp.security.passkeysnever pulls in
webauthn (and its cryptographychain). There is no stdlib WebAuthn
fallback, so a missing dependency raisesConfigurationErrorat first use
rather than degrading silently — the same fail-loud shape as
CookieSessionStore.__init__ / RedisSessionStore.__init__.
No parameters.
PasskeyConfig
class
Relying-party configuration for the WebAuthn ceremonies.
Static, serializable data — constructible withoutwebauthninstalled (so
the startup contract can inspect it). Misconfiguration fails loud at
construction; thepasskeyscontract check additionally surfaces the
secure-context / rp_id-suffix posture at startup.
PasskeyCredential
class
The shape a stored credential must expose for authentication.
The framework defines the shape; the app owns persistence (a row, an ORM
model, a dataclass — anything with these attributes). The verbs only read
public_key and sign_count; credential_idis the app's primary
lookup key anduser_idthe link to the app's user.
Recommended additional columns the app should store but the framework does
not require:transports, aaguid, backup_eligible/
backup_state (from credential_device_type / credential_backed_up),
nickname, last_used_at.
RegisteredCredential
class
The verified result of a registration ceremony, for the app to persist.
Binduser_idyourself when you store it (registration enrolls a
credential for the user the handler already identified — it does not
authenticate one).
AuthenticatedCredential
class
The verified result of an authentication ceremony.
Persistnew_sign_countagainst the stored credential before calling
login(). sign_count_regressedis the clone-detection signal — the
framework computes it; the response (lock, force re-auth, flag) is app
policy. Note most synced passkeys (iCloud Keychain, Google) always report a
sign count of 0, so this is a no-op for them and must not be relied on alone.
_b64u_encode
function
def _b64u_encode(data: bytes) -> str
Encode bytes as unpadded base64url (JSON-safe for the session serializer).
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
data
|
bytes
|
— |
_b64u_decode
function
def _b64u_decode(data: str) -> bytes
Decode unpadded base64url back to bytes.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
data
|
str
|
— |
_stash_challenge
function
def _stash_challenge(challenge: bytes, *, ttl: int) -> None
Store the challenge single-use in the session with an embedded deadline.
RaisesLookupError if no SessionMiddlewareis active — passkeys
require the secure-by-default stack, so this fails loud rather than silently
issuing an unverifiable ceremony.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
challenge
|
bytes
|
— | |
ttl
|
int
|
— |
_pop_challenge
function
def _pop_challenge() -> bytes | None
Pop the stored challenge, returning it only if present and unexpired.
Always removes the key (single-use, even on expiry/corruption) to defeat
replay. ReturnsNonefor a missing, expired, or malformed entry — the
caller raisesPasskeyChallengeError.
No parameters.
_extract_transports
function
def _extract_transports(credential: str | dict[str, Any]) -> tuple[str, ...] | None
Best-effort pull of authenticator transports from the client credential.
Transports live in the client response (response.transports), not in the
verified registration result, so the app should store them to scope future
allow_credentials. Returns Nonewhen absent/unparseable.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
credential
|
str | dict[str, Any]
|
— |
begin_registration
function
def begin_registration(*, user_id: bytes, user_name: str, user_display_name: str | None = None, exclude_credentials: list[bytes] | None = None, config: PasskeyConfig) -> dict[str, Any]
Mint registration options, stash the challenge, return options JSON.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
user_id
|
bytes
|
— | Opaque per-user handle as bytes (encode your user id, e.g. ``user.id.encode()``). Stamped into the credential; not the username. |
user_name
|
str
|
— | The account identifier the authenticator displays (email/handle). |
user_display_name
|
str | None
|
None
|
Friendly name; defaults to ``user_name``. |
exclude_credentials
|
list[bytes] | None
|
None
|
Raw ``credential_id`` bytes the user has already registered, so the authenticator refuses to re-enroll the same key. |
config
|
PasskeyConfig
|
— | The relying-party config. |
finish_registration
function
def finish_registration(*, credential: str | dict[str, Any], config: PasskeyConfig, require_user_verification: bool | None = None) -> RegisteredCredential
Pop the challenge, verify the attestation, return a credential to persist.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
credential
|
str | dict[str, Any]
|
— | The client's registration response (the JS bridge's POST envelope — a JSON string or already-parsed dict). |
config
|
PasskeyConfig
|
— | The relying-party config. |
require_user_verification
|
bool | None
|
None
|
Override the UV requirement; defaults to ``config.require_user_verification``. |
begin_authentication
function
def begin_authentication(*, allow_credentials: list[bytes] | None = None, config: PasskeyConfig) -> dict[str, Any]
Mint authentication options, stash the challenge, return options JSON.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
allow_credentials
|
list[bytes] | None
|
None
|
Raw ``credential_id`` bytes to restrict the ceremony to (username-first login). Omit / empty for usernameless (discoverable-credential) login. |
config
|
PasskeyConfig
|
— | The relying-party config. |
finish_authentication
function
def finish_authentication(*, credential: str | dict[str, Any], stored: PasskeyCredential, config: PasskeyConfig, require_user_verification: bool | None = None) -> AuthenticatedCredential
Pop the challenge, verify the assertion against the stored credential.
Does not calllogin() — the handler persists new_sign_countand
then callslogin(user)(the single identity-termination point). Consuming
the challenge here, before the handler'slogin(), is what keeps
regenerate_session()from wiping it.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
credential
|
str | dict[str, Any]
|
— | The client's authentication response (JSON string or dict). |
stored
|
PasskeyCredential
|
— | The persisted credential the client claims (looked up by id); must expose ``public_key`` and ``sign_count``. |
config
|
PasskeyConfig
|
— | The relying-party config. |
require_user_verification
|
bool | None
|
None
|
Override the UV requirement; defaults to ``config.require_user_verification``. |
View source · /home/runner/work/chirp/chirp/site/../src/chirp/security/passkeys.py:1