Passkey Authentication
July 20, 2026 · View on GitHub
Passkeys let users sign up and log in with WebAuthn credentials (Touch ID, Face ID, Windows Hello, or a hardware security key) instead of a password. This guide covers the primary authentication flow on ServerClient — signing a user up or in with a passkey and establishing a server-side session.
Note
Passkeys require a Custom Domain (WebAuthn binds the credential to the relying-party domain) and the native passkey feature enabled on your tenant. See the Auth0 passkey documentation.
Note
Managing a logged-in user's enrolled passkeys (enroll a new passkey, list, rename, delete) is a separate surface on the My Account API. See examples/MyAccountAuthenticationMethods.md.
Table of Contents
- How the flow works
- Prerequisites
- 1. Passkey Signup
- 2. Passkey Login
- 3. DPoP-bound passkey tokens (optional)
- Error Handling
How the flow works
A passkey ceremony is always two steps, because the WebAuthn signature happens in the browser between them:
- Challenge — the SDK asks Auth0 for a challenge (
passkey_signup_challenge/passkey_login_challenge). Auth0 returns anauth_sessionand the WebAuthn options (authn_params_public_key). - Browser — your front end passes those options to
navigator.credentials.create()(signup) ornavigator.credentials.get()(login). The authenticator produces a signed credential. - Verify / sign-in — the SDK exchanges the signed credential for tokens (
signin_with_passkey) and creates a server-side session, exactly like every other login path.
Prerequisites
from auth0_server_python.auth_server.server_client import ServerClient
server_client = ServerClient(
domain="YOUR_CUSTOM_DOMAIN",
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
secret="YOUR_SECRET",
)
The Passkey grant (urn:okta:params:oauth:grant-type:webauthn) must be enabled for your application under Applications → Your App → Grant Types.
1. Passkey Signup
Step 1 — Request a signup challenge
from auth0_server_python.auth_types import PasskeyUserProfile
challenge = await server_client.passkey_signup_challenge(
user_profile=PasskeyUserProfile(
email="new.user@example.com",
name="Jane Doe",
username="jane.doe",
phone_number="+15551234567",
given_name="Jane",
family_name="Doe",
nickname="Janie",
picture="https://example.com/jane.png",
),
connection="Username-Password-Authentication", # optional database connection (realm)
store_options={"request": request, "response": response},
)
# Hand these to the browser:
# challenge.auth_session -> opaque session credential (Tier 1, do not log)
# challenge.authn_params_public_key -> pass to navigator.credentials.create()
Tip
PasskeyUserProfile allows extra fields — any additional profile attribute your tenant accepts (for example given_name, family_name, picture) passes through without an SDK change. Pass tenant-specific custom data via the separate user_metadata argument.
Step 2 — Create the credential in the browser
Pass authn_params_public_key to navigator.credentials.create(). The resulting credential serializes to the shape the SDK expects in step 3 (id, rawId, type, and a response object with clientDataJSON + attestationObject).
Step 3 — Verify and establish the session
from auth0_server_python.auth_types import PasskeyAuthResponse
result = await server_client.signin_with_passkey(
auth_session=challenge.auth_session,
authn_response=PasskeyAuthResponse(
id=credential["id"],
raw_id=credential["rawId"], # accepts rawId alias too
type="public-key",
response={
"clientDataJSON": credential["response"]["clientDataJSON"],
"attestationObject": credential["response"]["attestationObject"],
},
),
store_options={"request": request, "response": response},
)
user = result.state_data["user"]
print(f"Signed up and logged in: {user['sub']}")
signin_with_passkey returns a PasskeyLoginResult whose state_data holds the user claims and token sets — the same shape as complete_interactive_login and login_with_custom_token_exchange. The session is persisted to your configured state store.
2. Passkey Login
Identical shape, different endpoints. The login challenge takes an optional username hint (for conditional UI), and the browser uses navigator.credentials.get().
# Step 1 — login challenge
challenge = await server_client.passkey_login_challenge(
username="existing.user@example.com", # optional
connection="Username-Password-Authentication", # optional
store_options={"request": request, "response": response},
)
# Step 2 — browser: navigator.credentials.get(challenge.authn_params_public_key)
# Step 3 — sign in. The login credential's response carries
# clientDataJSON + authenticatorData + signature + userHandle.
result = await server_client.signin_with_passkey(
auth_session=challenge.auth_session,
authn_response=PasskeyAuthResponse(
id=credential["id"],
raw_id=credential["rawId"],
type="public-key",
response={
"clientDataJSON": credential["response"]["clientDataJSON"],
"authenticatorData": credential["response"]["authenticatorData"],
"signature": credential["response"]["signature"],
"userHandle": credential["response"]["userHandle"],
},
),
store_options={"request": request, "response": response},
)
Note
The SDK is transparent to the signup-vs-login difference in the credential response — both flow through the same PasskeyAuthResponse.response dict. Send exactly the keys the browser produced.
3. DPoP-bound passkey tokens (optional)
Pass an optional dpop_key to bind the issued tokens to a key your server holds (RFC 9449), so a stolen token alone cannot be replayed. DPoP is opt-in: omit dpop_key and sign-in returns ordinary Bearer tokens with no behaviour change.
from jwcrypto import jwk
dpop_key = jwk.JWK.generate(kty="EC", crv="P-256") # you generate and keep this key (Tier 0)
result = await server_client.signin_with_passkey(
auth_session=challenge.auth_session,
authn_response=authn_response,
dpop_key=dpop_key,
store_options={"request": request, "response": response},
)
When dpop_key is supplied, the SDK attaches a token-endpoint proof so Auth0 issues a DPoP-bound token, transparently handles the server-nonce challenge, and rejects a Bearer downgrade — if the server returns an unbound token, signin_with_passkey raises PasskeyError rather than silently accepting a token bound to a key it never used.
Tip
Reuse the same dpop_key for any subsequent My Account API calls made with the resulting token — the token is bound to that one key. See MyAccountAuthenticationMethods.md → DPoP.
Warning
The dpop_key private key is a Tier 0 secret. Keep it in your secret store (KMS/HSM), never log it (repr() is redacted, but key.export_private() is not), use one key per user/session (never share across principals), and use EC P-256 only — any other key type fails closed with a ValueError before any network call.
Completing MFA on a passkey login (and where the session comes from)
When a passkey login needs a second factor, signin_with_passkey raises MfaRequiredError before it creates a session. You finish the login by challenging and verifying through client.mfa, then store the returned tokens yourself — on this path the SDK does not persist the session for you (persist defaults to False, and there is no existing session to update yet):
from auth0_server_python.error import MfaRequiredError
try:
result = await server_client.signin_with_passkey(
auth_session=auth_session,
authn_response=authn_response,
dpop_key=dpop_key, # optional; omit for Bearer tokens
store_options={"request": request, "response": response},
)
# No MFA needed: signin_with_passkey already persisted the session for you.
user = result.state_data["user"]
except MfaRequiredError as e:
# 1. Challenge the factor (e.g. an authenticator-app OTP).
await server_client.mfa.challenge_authenticator(
{"mfa_token": e.mfa_token, "factor_type": "otp"},
store_options={"request": request, "response": response},
)
# 2. Verify the user's code. Re-supply the SAME dpop_key so the issued
# token stays DPoP-bound. persist=False (the default) → the SDK returns
# the tokens instead of writing a session.
verify_response = await server_client.mfa.verify(
{"mfa_token": e.mfa_token, "otp": otp_code},
dpop_key=dpop_key, # same key given to signin_with_passkey
store_options={"request": request, "response": response},
)
# 3. Persist the tokens into YOUR session yourself — this is the step the
# SDK skips on the MFA path because no session existed at login time.
save_session_for_user(
access_token=verify_response.access_token,
id_token=verify_response.id_token,
refresh_token=verify_response.refresh_token,
)
Important
Re-supply the same dpop_key to verify. Omitting it when the login was DPoP-bound would downgrade the result to a Bearer token; verify rejects that mismatch with MfaVerifyError rather than silently dropping the sender constraint. DPoP is preserved end to end — persist=False affects only who writes the session, never the token binding.
Note
Do not pass persist=True on this path. It updates an existing session, and a passkey-first login has none yet, so it raises MfaVerifyError("No existing session found…") — discarding the tokens verify just obtained. Use persist=False and store the returned tokens as shown above. (This is pre-existing MFA-client behavior, unrelated to passkeys.)
Error Handling
The three passkey methods raise PasskeyError (a subclass of Auth0Error). Input-validation failures raise MissingRequiredArgumentError; a required step-up raises MfaRequiredError. For most code, catching Auth0Error is enough.
Basic handling (recommended)
from auth0_server_python.error import Auth0Error
try:
result = await server_client.signin_with_passkey(
auth_session=auth_session,
authn_response=authn_response,
store_options={"request": request, "response": response},
)
except Auth0Error as e:
return {"error": str(e)}
Advanced handling (when actions differ by case)
from auth0_server_python.error import PasskeyError, MfaRequiredError, Auth0Error
try:
result = await server_client.signin_with_passkey(
auth_session=auth_session,
authn_response=authn_response,
store_options={"request": request, "response": response},
)
except MfaRequiredError as e:
return start_mfa(e.mfa_token) # step-up required — challenge + verify via client.mfa,
# then store the returned tokens (see "Completing MFA
# on a passkey login" above)
except PasskeyError as e:
return {"error": e.code, "detail": e.message} # branch on e.code, never on message text
except Auth0Error as e:
return {"error": str(e)}
Common error codes (PasskeyErrorCode)
passkey_challenge_error— the signup/login challenge request failedpasskey_token_error— token exchange failed (also used for a rejected DPoP downgrade)invalid_response— Auth0 returned a response that could not be parsed
Note
auth_session is a short-lived (typically ~5 min) Tier 1 credential. It is redacted in the SDK's model repr(), and you should never log or persist it. If the ceremony takes too long, re-request the challenge.