Passkeys Beside Passwords

Enroll and authenticate WebAuthn passkeys with Chirp's ceremony verbs, BYO credential store, window.chirp.passkeys bridge, and app.check() passkeys category

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

Overview

Passkeys are one authenticator beside passwords, not a replacement for your identity core. Chirp's doctrine matches passwords: the framework owns the verb (the ceremony + the session-bound challenge lifecycle); your app owns the row (credential persistence), exactly like the password login walkthrough owns the user table but callshash_password / verify_login.

This tutorial wires the full loop:

  1. Register — enroll a credential for an already-identified user (@login_required)
  2. Authenticate — prove possession, then calllogin(user)yourself
  3. BridgeAppConfig(passkeys=True) injects window.chirp.passkeysfor the browser ceremony
  4. Startupapp.check() fires the passkeyscategory before you ship

Prerequisites: Python 3.14+, the auth stack from the login walkthrough, and the optional passkeys extra:

pip install "bengal-chirp[auth,passkeys]"
export CHIRP_SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(32))")

Missing webauthnfails loud at first ceremony use with a ConfigurationError— there is no stdlib fallback.

Relying-party config

PasskeyConfig validates the WebAuthn footgun at construction: rp_idmust be a registrable suffix of everyoriginyou accept.

from chirp.security.passkeys import PasskeyConfig

PK = PasskeyConfig(
    rp_id="localhost",
    rp_name="My App",
    origin="http://localhost:8000",  # production: https://app.example.com
)

On Railway or any HTTPS deploy, set both env vars to your public hostname — see examples/chirpui/lucky_cat/passkey_config.py.

BYO credential store

Implement persistence with any object exposingcredential_id, public_key, sign_count, and user_id— a dataclass, an ORM row, whatever you already use. The verbs only readpublic_key and sign_countduring authentication.

from dataclasses import dataclass
from chirp.security.passkeys import RegisteredCredential

@dataclass(frozen=True, slots=True)
class StoredPasskey:
    credential_id: bytes
    public_key: bytes
    sign_count: int
    user_id: str

After finish_registration, bind user_idand persist the row. After finish_authentication, update sign_count before calling login(user).

Ceremony routes

Each ceremony is a begin → finish pair.begin_*stashes a single-use challenge in the session and returns JSON for the JS bridge.finish_*pops the challenge before verifying — sologin()regenerate_session()can never wipe a not-yet-consumed challenge.

from webauthn.helpers import base64url_to_bytes

from chirp import JSONResponse, Request, current_user, login, login_required
from chirp.security.passkeys import (
    PasskeyVerificationError,
    begin_authentication,
    begin_registration,
    finish_authentication,
    finish_registration,
)

@app.route("/auth/passkey/register/begin", methods=["POST"])
@login_required
async def register_begin():
    user = current_user()
    options = begin_registration(
        user_id=user.id.encode(),
        user_name=user.id,
        user_display_name=user.name,
        exclude_credentials=[...],  # ids the user already enrolled
        config=PK,
    )
    return JSONResponse.from_value(options)

@app.route("/auth/passkey/register/finish", methods=["POST"])
@login_required
async def register_finish(request: Request):
    body = await request.json()
    try:
        registered = finish_registration(credential=body, config=PK)
    except PasskeyVerificationError:
        return JSONResponse.from_value({"error": "Registration failed."}, status=422)
    store.save(current_user().id, registered)
    return JSONResponse.from_value({"ok": True})

@app.route("/auth/passkey/login/begin", methods=["POST"])
async def login_begin():
    return JSONResponse.from_value(begin_authentication(config=PK))

@app.route("/auth/passkey/login/finish", methods=["POST"])
async def login_finish(request: Request):
    body = await request.json()
    stored = store.get(base64url_to_bytes(body["id"]))
    if stored is None:
        return JSONResponse.from_value({"error": "Unknown passkey."}, status=422)
    try:
        verified = finish_authentication(credential=body, stored=stored, config=PK)
    except PasskeyVerificationError:
        return JSONResponse.from_value({"error": "Authentication failed."}, status=422)
    store.update_sign_count(stored.credential_id, verified.new_sign_count)
    login(await load_user(stored.user_id))  # ← single identity-termination point
    return JSONResponse.from_value({"ok": True, "redirect": "/dashboard"})

Browser bridge + CSRF

Enable the bridge in config:

config = AppConfig(..., passkeys=True)

Chirp injects window.chirp.passkeys.register() / .authenticate()before </body>. The JS POST is not a template <form>, so include the CSRF token yourself:

const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
const opts = await (await fetch('/auth/passkey/login/begin', {
  method: 'POST',
  headers: csrf ? {'X-CSRF-Token': csrf} : {},
})).json();
const credential = await chirp.passkeys.authenticate(opts);
await fetch('/auth/passkey/login/finish', {
  method: 'POST',
  headers: {'Content-Type': 'application/json', ...(csrf ? {'X-CSRF-Token': csrf} : {})},
  body: JSON.stringify(credential),
});

Errors thrown by the bridge carry .passkeyReason of cancelled, duplicate, misconfigured, unsupported, or failed.

app.check() passkeys category

Whenpasskeys=True, startup checks two tracks (see passkeys category):

Check Severity Fix
webauthnnot installed ERROR (all envs) pip install chirp[passkeys]
Cookie session store + passkeys WARNING (prod/staging) PreferRedisSessionStoreor shorten session TTL

Runchirp check --deploybefore shipping to catch production-only posture failures.

Clone-and-run references

Example What it proves
examples/standalone/passkeys_minimal/ Slim password + passkey reference (#463)
examples/chirpui/lucky_cat/ Product-shaped login + settings enrollment (#464)

Browser e2e with a virtual authenticator:pytest -m passkeys_e2eon passkeys_minimal/test_browser_smoke.py(#465).