Testing passkeys without a browser
Drive the WebAuthn register + authenticate flow end-to-end from a Python test using SoftAuthenticator.
WebAuthn flows are normally exercised by a browser + a hardware or platform
authenticator. That's hard to script in CI. Kernia ships SoftAuthenticator
in kernia_test_utils — a fully software WebAuthn authenticator that emits
real CBOR attestation objects and ES256 signatures.
The webauthn library's strict verifier accepts its output, so you can drive
the full register → authenticate cycle end-to-end without any browser.
Setup
SoftAuthenticator lives in kernia_test_utils:
from kernia_test_utils import ASGIDriver, SoftAuthenticator
from webauthn.helpers import base64url_to_bytesIt generates a fresh P-256 keypair per credential, builds COSE_Key payloads
(RFC 8152 §13.1.1), wraps them in a CBOR fmt:"none" attestation object,
and signs assertions with sha256(authData || clientDataHash) exactly the
way a hardware token does.
Full register + authenticate flow
RP_ID = "localhost"
ORIGIN = "http://localhost:3000"
@pytest.mark.asyncio
async def test_full_passkey_register_and_authenticate() -> None:
auth = init(KerniaOptions(
database=memory_adapter(),
secret="test-secret",
plugins=[
email_and_password(),
passkey(rp_id=RP_ID, rp_name="Test", origin=ORIGIN),
],
))
driver = ASGIDriver(app=auth.router.mount())
await driver.request(
"POST", "/sign-up/email",
json_body={"email": "user@example.com", "password": "correcthorse"},
)
authenticator = SoftAuthenticator()
# --- registration -----------------------------------------------------
r = await driver.request("POST", "/passkey/register/start", json_body={})
options = r.json()["options"]
challenge = base64url_to_bytes(options["challenge"])
attestation = authenticator.register(
challenge=challenge, origin=ORIGIN, rp_id=RP_ID,
)
r = await driver.request(
"POST", "/passkey/register/finish", json_body={"response": attestation},
)
cred_id_b64 = r.json()["credentialId"]
# --- authentication ---------------------------------------------------
auth_driver = ASGIDriver(app=auth.router.mount()) # fresh session
r = await auth_driver.request("POST", "/passkey/authenticate/start", json_body={})
auth_challenge = base64url_to_bytes(r.json()["options"]["challenge"])
assertion = authenticator.authenticate(
challenge=auth_challenge, origin=ORIGIN, rp_id=RP_ID,
credential_id=cred_id_b64,
)
r = await auth_driver.request(
"POST", "/passkey/authenticate/finish", json_body={"response": assertion},
)
assert "better-auth.session_token" in auth_driver.cookiesNegative-path tests
The same fixture is useful for security tests because it can produce invalid outputs by feeding it the wrong inputs.
Forged signature. Register one authenticator, then have a different
authenticator try to authenticate while claiming the same credential id.
Its private key won't match the stored public key, so the server's
verify_authentication_response must reject:
impostor = SoftAuthenticator()
impostor.register(challenge=b"x" * 32, origin=ORIGIN, rp_id=RP_ID)
assertion = impostor.authenticate(challenge=auth_challenge, origin=ORIGIN, rp_id=RP_ID)
# Force the impostor's assertion to advertise the REAL credential id —
# the public key the server looks up won't match.
assertion["id"] = real_cred_id
assertion["rawId"] = real_cred_id
r = await driver.request("POST", "/passkey/authenticate/finish", json_body={"response": assertion})
assert r.status >= 400Tampered challenge. Get a real challenge, then sign a different one. The clientDataJSON's challenge field won't match what the server issued, so attestation verification must reject:
r = await driver.request("POST", "/passkey/register/start", json_body={})
fake = authenticator.register(challenge=b"\x00" * 32, origin=ORIGIN, rp_id=RP_ID)
r = await driver.request("POST", "/passkey/register/finish", json_body={"response": fake})
assert r.status == 400
assert r.json()["code"] == "INVALID_PASSKEY_ATTESTATION"Limitations
SoftAuthenticatoronly producesfmt:"none"attestations. If your deployment requirespacked,tpm, or other attestation formats with a real trust chain, the soft authenticator can't exercise that path.- It uses ES256 (P-256) exclusively. Other algorithms aren't generated.
- This is a test fixture; never use it in production.