security.passkeys

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

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…

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

class PasskeyError

Base for passkey ceremony failures.

Jump to symbol
class PasskeyChallengeError

No usable challenge was found for a finish ceremony.

The challenge was missing, expired, or already consumed (replay). Surface a generic "please try again"…

Jump to symbol
class PasskeyVerificationError

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…

Jump to symbol
function _has_webauthn

ReturnTrue if the optional webauthnpackage is importable.

Used by thepasskeys startup contract check (rules_passkeys) — the same find-spec probe…

Jump to symbol
function _require_webauthn

Importwebauthnlazily, or fail loud with an actionable message.

Imported inside the verb functions (never at module top level) so import chirp/ …

Jump to symbol
class PasskeyConfig

Relying-party configuration for the WebAuthn ceremonies.

Static, serializable data — constructible withoutwebauthninstalled (so the startup contract can inspect it). Misconfiguration fails loud…

Jump to symbol
class PasskeyCredential

The shape a stored credential must expose for authentication.

The framework defines the shape; the app owns persistence (a row, an ORM model, a…

Jump to symbol
class RegisteredCredential

The verified result of a registration ceremony, for the app to persist.

Binduser_idyourself when you store it (registration enrolls a credential for…

Jump to symbol
class AuthenticatedCredential

The verified result of an authentication ceremony.

Persistnew_sign_countagainst the stored credential before calling login(). sign_count_regressedis the clone-detection signal— the…

Jump to symbol
function _b64u_encode

Encode bytes as unpadded base64url (JSON-safe for the session serializer).

Jump to symbol
function _b64u_decode

Decode unpadded base64url back to bytes.

Jump to symbol
function _stash_challenge

Store the challenge single-use in the session with an embedded deadline.

RaisesLookupError if no SessionMiddlewareis active — passkeys require the secure-by-default stack,…

Jump to symbol
function _pop_challenge

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…

Jump to symbol
function _extract_transports

Best-effort pull of authenticator transports from the client credential.

Transports live in the client response (response.transports), not in the verified registration result,…

Jump to symbol
function begin_registration

Mint registration options, stash the challenge, return options JSON.

Jump to symbol
function finish_registration

Pop the challenge, verify the attestation, return a credential to persist.

Jump to symbol
function begin_authentication

Mint authentication options, stash the challenge, return options JSON.

Jump to symbol
function finish_authentication

Pop the challenge, verify the assertion against the stored credential.

Does not calllogin() — the handler persists new_sign_countand then callslogin(user)(the…

Jump to symbol
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