Skip to content

feat: passkey id verification - #183

Open
rmad17 wants to merge 17 commits into
mainfrom
feat/passkey-id-verification
Open

rmad17 wants to merge 17 commits into
mainfrom
feat/passkey-id-verification

Conversation

@rmad17

@rmad17 rmad17 commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Native passkey signup can now verify a user's email or phone before any tokens are issued. When a connection requires it, the signup challenge tells you which identifiers need a code, and the sign-in call takes the codes the user entered. Connections that don't require verification follow the same flow as before.

A wrong code doesn't end the signup. Auth0 keeps the session alive, and PasskeyError now carries the details a caller needs to re-prompt for just the identifiers still unverified. Every error response from the three passkey calls also sets status_code and retry_after on PasskeyError, so a caller detects a rate limit with e.status_code == 429. The three passkey methods also take an optional client_ip, which is forwarded as the auth0-forwarded-for header.

Behavior changes to be aware of:

  • Printing or logging a passkey challenge no longer shows auth_session. The value is still available as challenge.auth_session and in model_dump().
  • A ValidationError from a passkey challenge or token response no longer shows the response body in str() or repr(). e.errors() and e.json() still include it, which is pydantic's behavior.
  • With a dpop_key, signin_with_passkey retries with the server's nonce only when the error is use_dpop_nonce, as RFC 9449 specifies. Before, any 400 or 401 that carried a DPoP-Nonce header was retried once.
  • An error body that is valid JSON but not an object keeps the same code, but the message is now "Passkey ... failed with status N" and status_code is set.

Changelog

Requests

  • POST /passkey/register now accepts an optional delivery_method ("text" or "voice").
  • POST /oauth/token (passkey grant) now accepts an optional verification object.
  • POST /passkey/register, POST /passkey/challenge and POST /oauth/token (passkey grant) send auth0-forwarded-for when client_ip is set.

Added

  • ServerClient.passkey_signup_challenge(..., delivery_method=None) forwards the phone code delivery channel. Any value other than "text" or "voice" raises InvalidArgumentError before a request is sent. The check is case-sensitive.
  • ServerClient.signin_with_passkey(..., verification=None) forwards the codes, keyed by identifier.
    • Only email and phone are accepted. A verification that isn't a dict, any other key, or a code that isn't a string raises InvalidArgumentError before a request is sent.
    • Blank or None codes are treated as not provided, and the field is omitted if nothing is left.
  • client_ip=None on passkey_signup_challenge, passkey_login_challenge and signin_with_passkey, sent as the auth0-forwarded-for header and omitted when not set.
  • PasskeySignupChallengeResponse.verification_required, a list of identifiers, or None when nothing needs verifying.
  • PasskeyDeliveryMethod, a Literal["text", "voice"] type alias.
  • PasskeyError.auth_session and PasskeyError.verification_required, populated from the token
  • PasskeyError.is_verification_retryable, true for invalid_grant with an auth_session, which is how Auth0 signals the session is still alive.
  • PasskeyError.status_code and PasskeyError.retry_after, set on every error response from thNone otherwise. retry_after is the Retry-After header in whole seconds, and None when theheader is missing, a date, non-numeric or negative.
  • PasskeyErrorCode.INVALID_GRANT ("invalid_grant").
  • Docstrings for the passkey models in auth_types.

Changed

  • auth_session is hidden from repr() and str() on PasskeySignupChallengeResponse and PasskeyLoginChallengeResponse.
  • PasskeySignupChallengeResponse, PasskeyLoginChallengeResponse and PasskeyTokenResponse set.
  • The signin_with_passkey DPoP nonce retry also requires a JSON body with error == "use_dpop_nonce".

Testing

poetry run pytest passes (837 tests) and poetry run ruff check . is clean.

New tests cover:

  • Parsing verification_required when it's absent, email only, phone only and both.
  • Forwarding verification and delivery_method, including alongside a DPoP key, and leaving delivery_method out when it isn't set.
  • Sending verification as a JSON object on the wire, through a real httpx client on a MockTr
  • Rejecting an unsupported delivery_method, a verification that isn't a dict, unknown keys and non-string codes before any request is made, and dropping blank codes.
  • Sending client_ip as auth0-forwarded-for on all three calls, including alongside a DPoP keout when it isn't set.
  • Error classification: wrong code (retryable), missing code, terminal invalid_grant, a 403 for an unknown session, and a server error.
  • A wrong code followed by a corrected code on the same session.
  • 429 on the signup challenge, the login challenge and the token exchange, with JSON and plain-text bodies, keeping the released code and setting status_code. Also a 429 after a DPoP nonce retry.
  • retry_after parsing for seconds, a missing header, a date, a non-numeric value and a negate on a 503 and on a JSON error body that isn't an object.
  • No DPoP retry when the DPoP-Nonce header comes without use_dpop_nonce.
  • auth_session absent from repr() and str() on both challenge models and on PasskeyError, an from ValidationError text.

Manually Tested Flows

# Scenario
1 Email signup with OTP, happy path
2 Wrong code, then correct code
3 Terminal failure after repeated wrong codes
4 Login with the new passkey
5 Signup with an existing account's email
6 Phone-only signup, phone attribute not enabled
7 Email + phone, delivery_method: text

@rmad17 rmad17 changed the title Feat/passkey id verification feat: passkey id verification Oct 11, 2026
@rmad17
rmad17 marked this pull request as ready for review October 12, 2026 04:56
@rmad17
rmad17 requested a review from a team as a code owner October 12, 2026 04:56

error = await _signin_expecting_error(client)

assert error.code == "invalid_request"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good case if invalid_request is returned and auth_session is present then it is retryable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should keep it as it is. Auth0 never sends auth_session with invalid_request. For a missing code the body has only error and error_description, but the session is still valid, so the app can retry with the same auth_session. The docs already say this.

invalid_grant with auth_session is also the rule in auth-js (isRetryable), so the SDKs behave the same.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it as it is for now.

url = f"https://{domain}{self.PASSKEY_REGISTER_PATH}"
response = await client.post(url, json=body)

if response.status_code == 429:

@nandan-bhat nandan-bhat Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the code we raise for a 429. Before this PR, the token call gave access_denied, and a plain text 429 on the challenge calls gave passkey_challenge_error. Now all of them give too_many_requests.

As per the repo guidelines (the Ask First section), changing the code of an existing error is a breaking change and needs an ok from the maintainers first. I am fine with too_many_requests since it matches PasswordlessErrorCode.TOO_MANY_REQUESTS. Can we get that sign off here and add a line in the release notes?

Also, the "Behavior changes" list in the PR description does not mention passkey_signup_challenge currently, will be updating it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for flagging this. I went with the non-breaking option, so there's no code change to sign off on. A 429 now raises the same code and message as before this PR: the server's error and error_description when the body is JSON (for example access_denied from the token endpoint limits), or passkey_challenge_error / passkey_token_error when it isn't.

To detect a rate limit, PasskeyError now has status_code (the HTTP status of the error response) and retry_after, so the check is e.status_code == 429. Both are new attributes that default to None. That matches auth0-server-js#307, which keeps the server's error and checks statusCode === 429. I removed PasskeyErrorCode.TOO_MANY_REQUESTS. It was never released, and it suggested a single code that the plain-text and access_denied responses don't use. Fixed in 79af3b9.

organization: Optional[str] = None,
user_metadata: Optional[dict[str, Any]] = None,
store_options: Optional[dict[str, Any]] = None,
delivery_method: Optional[PasskeyDeliveryMethod] = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the PR description we decided not to forward the end user IP. But for confidential clients with the "Trust Token Endpoint IP Header" setting, Auth0 uses auth0-forwarded-for as the end user IP. Our own Passwordless.md explains this, and passwordless already has client_ip for it.

With identifier verification every signup sends a code, so this matters more now. Without it all of them look like they come from our server IP.

Can we add the same optional client_ip to the three passkey methods? A follow up PR is also fine, but then please update the reason in the description.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in this PR (c757b4d). passkey_signup_challenge, passkey_login_challenge and signin_with_passkey take an optional client_ip and send it as auth0-forwarded-for, using the same header constant as passwordless. The header is left out when client_ip isn't set. The Operational notes in Passkeys.md explain the Trust Token Endpoint IP Header setting and say to forward only a trusted, normalized IP, because with identifier verification on, Auth0 rejects a value that isn't a valid IP address.

if resolved_org:
body["organization"] = resolved_org
if delivery_method:
body["delivery_method"] = delivery_method

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small one. PreferredAuthMethod in the same module uses "sms", so it is easy to pass delivery_method="sms" here by mistake.

Today we send it as it is and Auth0 rejects it with invalid_request. A quick check for "text" or "voice" will give a clearer error.

"certificate-bound."
)
verification_codes: dict[str, str] = {}
for identifier, code in (verification or {}).items():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone passes a string or a list here, this fails with AttributeError: 'str' object has no attribute 'items' instead of InvalidArgumentError. Can we check isinstance(verification, dict) first?

Also, the type hint says dict[str, str], but None values are allowed and tested. dict[str, Optional[str]], or a small TypedDict with email and phone, will be more correct.

headers["DPoP"] = make_dpop_proof_for_token_endpoint(
dpop_key, "POST", token_endpoint, nonce=nonce
)
response = await client.post(token_endpoint, json=body, headers=headers)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not from this PR, but it matters more now. This retry runs for any 400 or 401 with a DPoP-Nonce header, without checking for use_dpop_nonce. RFC 9449 allows the server to send a new nonce on other responses also.

Today we never reuse an old nonce, so it does not happen. But if nonce caching is added later, a wrong code will be sent twice and will use two attempts. Can we check error == "use_dpop_nonce" before retrying?

Comment thread examples/Passkeys.md Outdated
if not e.is_verification_retryable or attempt == MAX_ATTEMPTS - 1:
raise
auth_session = e.auth_session
codes = ask_user_for_codes(e.verification_required)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop is fine for a script, but in a web app the new code comes in a new HTTP request from the browser. ask_user_for_codes() cannot wait inside the same request.

Can we show it as two requests? The first one returns verification_required to the browser. The next one calls signin_with_passkey again with the auth_session from the error and the same credential.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The loop is gone, and the example is now a handler that makes one signin_with_passkey call per request. On a wrong code it returns a 400 JSONResponse with verification_required and the auth_session from the error, and the browser asks for those codes only and posts again with the same credential. On success it returns no tokens, since the session is already stored server side. The guide also says to cap attempts in the app and to send one request at a time per auth_session. Fixed in 6068115 and d5cd0d2.

Comment thread examples/Passkeys.md Outdated
- Too many wrong codes, an expired code, or an expired session. Start again from the signup challenge.
- A missing code raises `invalid_request` and the session stays valid. Resubmit with the same `auth_session` and every required code.
- A server error (`server_error`) may or may not have ended the session. Retry with the same `auth_session`, `authn_response` and codes. If the call fails with `invalid_grant` and no `auth_session`, start again from the signup challenge.
- Any other failure is raised with the code Auth0 returned.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add the 429 case to this list also? For signin_with_passkey, Auth0 checks the rate limit before it uses the session, so the session is still valid after a too_many_requests error.

The app can wait and send the same auth_session, credential and codes again. With the loop above it will raise instead, and the user may start a new signup, which sends new codes again.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nandan-bhat 429 is a terminal error right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the 429 case to the list. The app waits (retry_after seconds when Auth0 sends Retry-After) and resends the same auth_session, credential and codes. The guide says to check status_code == 429 rather than code, because the code depends on which limit was hit. The token endpoint limits answer with access_denied, not too_many_requests (see the error-code thread in server_client.py). Fixed in 6068115, 15e9640 and 79af3b9.

@NandanPrabhu, I believe is that it isn't terminal. The /oauth/token rate limits run before client authentication and before the passkey grant claims the auth_session, so a 429 should leave the session and its attempts untouched. The one limit would be if the session expires before the app resends, the app starts again from the signup challenge. Is there a case where a 429 does end the session?

Comment thread examples/Passkeys.md Outdated
- A server error (`server_error`) may or may not have ended the session. Retry with the same `auth_session`, `authn_response` and codes. If the call fails with `invalid_grant` and no `auth_session`, start again from the signup challenge.
- Any other failure is raised with the code Auth0 returned.

If the connection drops after Auth0 accepts the final code, the session has ended because each code works once. Start again from the signup challenge.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the response is lost after Auth0 accepts the final code, the user is most likely created already. Starting the signup again with the same email then fails with invalid_request ("User signup failed"), so the user gets stuck.

The same happens when a Post Login Action denies the login (access_denied with a 500), because the user is created before the Actions run.

Can we say here that the account may already exist, so the app should offer sign in instead of a new signup?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added after the list. It covers both cases you named: the connection drops after Auth0 accepts the final code, or a Post Login Action denies the login. In both the account may already exist, because Auth0 creates the user before Actions run, so a new signup with the same email fails with invalid_request. The app should show a generic message that offers sign in, without confirming whether the account exists. I also noted that invalid_request covers invalid input too, so apps shouldn't read it alone as "the account exists". Fixed in 6068115 and 7989937.

@pytest.mark.asyncio
async def test_passkey_signup_challenge_rate_limited_json_raises_too_many_requests(mocker):
client = _make_passkey_client()
_mock_passkey_post(mocker, 429, {"error": "rate_limit_exceeded", "error_description": "slow down"})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small thing. Auth0 does not send rate_limit_exceeded. For the challenge endpoints a 429 is either plain text or too_many_requests (same for the access_denied body at line 9552). Not a blocker since we ignore the body now, but real bodies make these tests easier to trust.

Can we also add one test which checks that verification goes on the wire as a JSON object? All the tests here patch AsyncClient.post, so the encoding is never checked. auth-js had this exact bug.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. rate_limit_exceeded is gone, and the 429 test now uses the bodies you listed: plain text or too_many_requests for the two challenges, and plain text or access_denied ("Too Many Requests") for the token call. The bodies matter again now that the error code comes from Auth0's response, and the test asserts that code plus status_code == 429 for each case.

test_signin_with_passkey_verification_sent_as_json_object_on_the_wire patches _get_http_client to return a real httpx.AsyncClient on an httpx.MockTransport, so httpx does the encoding. It parses the actual request bytes and asserts the application/json content type and that verification is a JSON object with the codes. Fixed in c757b4d and 79af3b9.


error = await _signin_expecting_error(client)

assert error.code == "invalid_request"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should keep it as it is. Auth0 never sends auth_session with invalid_request. For a missing code the body has only error and error_description, but the session is still valid, so the app can retry with the same auth_session. The docs already say this.

invalid_grant with auth_session is also the rule in auth-js (isRetryable), so the SDKs behave the same.

Comment thread examples/Passkeys.md

> [!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.
> 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rmad17 are these changes necessary? these look like fixing '-' symbol

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary but good to have.

  1. We usually avoid putting em/en dashes in the repo.
  2. Tier0 is not a Auth0 classification, and is misleading here.

Comment thread examples/Passkeys.md
```python
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can add that in case developer passes voice for email the flow will still succeed with text but delivery method is specifically for phone identifier

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added below the example: "delivery_method applies only to the phone code. If only email needs a code, the value has no effect." The same paragraph now also says that any value other than "text" or "voice" raises InvalidArgumentError before the request is sent (see the delivery_method thread in server_client.py). Fixed in 6068115 and 79af3b9.

rmad17 and others added 4 commits October 19, 2026 10:36
Add the verification flow, resend and wrong-code handling, and operational
notes. Correct the claim that collecting codes first prevents a saved
passkey, since Auth0 checks codes only in signin_with_passkey. Add the
client_ip option and the rate-limit and lost-response guidance.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
- Add optional client_ip to the three passkey methods, sent as auth0-forwarded-for.
- Reject a delivery_method other than text or voice, and a non-dict verification, before any request.
- Retry the DPoP nonce on the passkey token call only for use_dpop_nonce.
- Add retry_after to PasskeyError and keep Auth0's error_description on a 429.
- Hide input from validation errors on the passkey challenge and token models.
- Use realistic 429 bodies in tests and check verification is sent as a JSON object.

Co-Authored-By: Claude <noreply@anthropic.com>
A 429 now raises the same code as before (the server's error, or the
SDK default for a non-JSON body) instead of a new too_many_requests code.
PasskeyError gains status_code and retry_after on every non-200 response,
so callers detect a rate limit with status_code == 429. A JSON error body
that is not an object keeps the default code and the status.

Removes the unreleased PasskeyErrorCode.TOO_MANY_REQUESTS and updates the
passkey guide for status_code, retry_after, invalid_grant and input
validation.

Co-Authored-By: Claude <noreply@anthropic.com>
retry_after = None
try:
retry_after = int(response.headers.get("Retry-After"))
except (TypeError, ValueError):
rmad17 and others added 3 commits October 19, 2026 15:47
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants