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.
- How the flow works
- Prerequisites
- 1. Passkey Signup
- 2. Passkey Login
- 3. DPoP-bound passkey tokens (optional)
- 4. Identifier verification (optional)
- Error Handling
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.
from auth0_server_python.auth_server.server_client import ServerClient
# Passkey sign-in persists a server-side session, and the challenge step stores
# short-lived transaction data, so ServerClient needs a state store and a
# transaction store (it does not create defaults). If your framework wrapper
# (e.g. auth0-fastapi) already provides these, reuse them. Otherwise, create
# your own implementations of the StateStore / TransactionStore ABCs.
state_store = YourStateStore(...) # or the store your framework provides
transaction_store = YourTransactionStore(...) # or the store your framework provides
server_client = ServerClient(
domain="YOUR_CUSTOM_DOMAIN",
client_id="YOUR_CLIENT_ID",
client_secret="YOUR_CLIENT_SECRET",
secret="YOUR_SECRET",
state_store=state_store,
transaction_store=transaction_store,
)For store implementations (cookie, Redis, database) and how to pass store_options, see examples/ConfigureStore.md.
The Passkey grant (urn:okta:params:oauth:grant-type:webauthn) must be enabled for your application under Applications → Your App → Grant Types.
Note
The passkey challenge endpoints accept a client secret only, not a client assertion. A client configured with just a client_assertion_signing_key (Private Key JWT) cannot use the passkey flows, so configure a client_secret to use them.
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()
# challenge.verification_required -> codes to collect first, when set (see section 4)
# auth_session does not appear in print(challenge). To inspect it while debugging, read challenge.auth_session.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.
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).
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"]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.
Note
If your connection requires users to verify their email or phone at signup, passkey_signup_challenge also accepts a delivery_method and signin_with_passkey accepts a verification argument. Both are optional and covered in section 4.
Identical shape, different endpoints. The browser uses navigator.credentials.get() and the user picks a passkey from the prompt. That credential identifies the user, so the login challenge takes no username.
# Step 1 - login challenge
challenge = await server_client.passkey_login_challenge(
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.
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
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 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.
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.
When a connection requires users to verify their email or phone number at signup, the signup challenge lists the identifiers that need a code, Auth0 sends each code, and you pass the codes to signin_with_passkey. Connections without verification follow the flow in section 1.
Enable identifier verification for email, phone, or both on your database connection. For email, choose the One-Time Password (OTP) method, because this flow passes codes and does not support verification links.
- Configure an email provider for email codes and a phone provider for phone codes. Without one the code is never sent, so the user can't finish signing up. If Auth0 can't start verification,
passkey_signup_challengeraisesPasskeyErrorwithstatus_code500 andcodeserver_error, and no session is created. Check the email or phone provider first. - Upgrade your app to an SDK version whose
signin_with_passkeyacceptsverificationbefore you turn verification on. Older versions can't send the codes, so every signup on the connection fails.
- Request the challenge. If
verification_requiredis set, collect a code for each identifier it lists. - Collect the codes before creating the credential, so a user who never receives a code doesn't end up with a passkey for an account that was never created. This order is a UX requirement, and the SDK can't enforce it. Your front end must not call
navigator.credentials.create()until every required code is collected. Auth0 checks the codes only insignin_with_passkey, after the credential exists, so a wrong code still leaves a passkey on the device with no account behind it. The retry reuses that credential. - Create the credential in the browser, then call
signin_with_passkeywith the codes inverification.
challenge = await server_client.passkey_signup_challenge(
user_profile=PasskeyUserProfile(email="new.user@example.com", phone_number="+15551234567"),
delivery_method="text", # optional, "text" or "voice" for the phone code
store_options={"request": request, "response": response},
)
# For example ["email", "phone"], or None when nothing needs verifying
required = challenge.verification_required
result = await server_client.signin_with_passkey(
auth_session=challenge.auth_session,
authn_response=authn_response,
verification={"email": email_code, "phone": phone_code},
store_options={"request": request, "response": response},
)verification must be a dict whose keys are email or phone, as listed in verification_required, and whose values are strings. Anything else raises InvalidArgumentError before any request is sent. Blank codes are ignored. Omit verification when verification_required is None.
delivery_method applies only to the phone code. If only email needs a code, the value has no effect. Any value other than "text" or "voice" raises InvalidArgumentError before the request is sent. A valid value can still be refused. When a phone code is sent and the phone provider doesn't support the channel, Auth0 returns 400 invalid_request with "The requested media is not supported for this connection", raised as a PasskeyError.
If the user does not receive a code, call passkey_signup_challenge again before the passkey is created. Each call sends new codes for every identifier and returns a new auth_session and new authn_params_public_key, so use the values from the latest response. The previous session stays valid until it expires.
After a wrong code the session stays valid. signin_with_passkey raises a PasskeyError with is_verification_retryable set to True, and its verification_required lists the identifiers still unverified.
Each attempt is one HTTP request. On a wrong code, the handler returns verification_required and the auth_session from the error. The browser asks for those codes only and posts again with the same credential. Cap the attempts in your app, because Auth0 also limits them.
from fastapi.responses import JSONResponse
from auth0_server_python.auth_types import PasskeyAuthResponse
from auth0_server_python.error import PasskeyError
async def passkey_signin(request, response, body):
# The browser posts JSON, so build the model first. This example assumes the page
# posts only id, rawId, type and response with clientDataJSON and attestationObject.
# A full credential.toJSON() also puts transports and publicKeyAlgorithm in response,
# which fail validation. Drop those keys from response before calling model_validate.
authn_response = PasskeyAuthResponse.model_validate(body["authn_response"])
try:
await server_client.signin_with_passkey(
auth_session=body["auth_session"],
authn_response=authn_response,
verification=body.get("verification"),
store_options={"request": request, "response": response},
)
except PasskeyError as e:
if not e.is_verification_retryable:
raise # other failures are listed below
# Send the unverified identifiers back so the page asks only for those codes.
return JSONResponse(
{"verification_required": e.verification_required, "auth_session": e.auth_session},
status_code=400,
)
# The result holds the tokens, so keep it on the server.
return {"signed_in": True}Send one signin_with_passkey request at a time per auth_session. Parallel requests can use up the allowed attempts, or reach Auth0 after the session has already been used.
is_verification_retryable is False in these cases.
- Too many wrong codes, an expired code, or an expired session. Start again from the signup challenge.
- A missing code raises
invalid_requestand the session stays valid. Resubmit with the sameauth_sessionand every required code. - A server error sets
status_codeto 500 or higher.codeis the response body'serror, orpasskey_token_errorwhen there is none. - A rate limit (
status_codeis 429). Auth0 rejects the request before it uses the attempt, so wait and resend the same request with the sameauth_session,authn_responseand codes. Whenretry_afteris set, wait that many seconds. Checkstatus_code, notcode, because the code depends on which limit was hit. - Any other failure is raised with the
errorcode Auth0 returned, orpasskey_token_errorwhen the response has no JSON error body.
If the connection drops after Auth0 accepts the final code, or a Post Login Action denies the login, the account may already exist, because Auth0 creates the user before Actions run. A new signup with the same email then fails with invalid_request. Show a generic message that offers sign in, without confirming whether the account exists. invalid_request also covers invalid input, so don't treat it alone as proof that the account exists.
- Rate-limit the route that calls
passkey_signup_challenge. Each call sends codes, so add limits per IP address and per email address or phone number. - Pass
client_iptopasskey_signup_challenge,passkey_login_challengeandsignin_with_passkeyto forward the end user's IP address as theauth0-forwarded-forheader. Without it Auth0 sees your server's address. Auth0 uses the header when the Trust Token Endpoint IP Header setting is enabled for the application, and, while identifier verification is enabled for your tenant, it rejects a value that is not a valid IP address. Forward only a trusted, normalized IP from your edge or proxy layer. - Do not log the verification codes.
Important
If your tenant requires DPoP, pass dpop_key to signin_with_passkey as described in section 3.
The three passkey methods raise PasskeyError, a subclass of Auth0Error. Input-validation failures raise MissingRequiredArgumentError or InvalidArgumentError, and a required step-up raises MfaRequiredError.
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)}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:
if e.status_code == 429:
return {"error": "rate_limited", "retry_after": e.retry_after}
return {"error": e.code, "detail": e.message} # branch on e.code or e.status_code, never on message text
except Auth0Error as e:
return {"error": str(e)}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 parsedinvalid_grant- a verification code was wrong, or the session is no longer valid.is_verification_retryabletells the two apart.invalid_request- Auth0 rejected the request as invalid, for example a missing verification code. On its own it does not mean the session can be resumed.
PasskeyError also exposes status_code (the HTTP status when Auth0 returned an error response, otherwise None), retry_after (seconds from the Retry-After header, or None when the header is absent or not a whole number of seconds), auth_session, verification_required and is_verification_retryable for identifier verification. A rate-limited challenge or token exchange has status_code 429, and its code depends on which Auth0 limit was hit. The per-IP limit on the challenge endpoints applies to public clients only. Treat auth_session on the error like any other session credential and keep it out of logs.
Note
auth_session is a short-lived (typically about a minute, or 15 min when verification_required is set, matching the timeout in authn_params_public_key) credential. Never log or persist it. If the ceremony takes too long, re-request the challenge.