Skip to Content
TutorialsNative sign-in with MFA

Sign in from a native application with MFA

This tutorial authenticates an existing user with email and password, completes TOTP or recovery-code verification when required, then exchanges the resulting authorization code. The credential endpoints never issue tokens directly.

Before you start

The OAuth client must be NATIVE, public, configured with tokenEndpointAuthMethod: NONE and explicitly enabled for native authentication. Register its exact custom-scheme or claimed-HTTPS callback.

Generate a fresh PKCE verifier and S256 challenge, random state and OIDC nonce for every attempt. Keep the verifier in platform-protected temporary storage.

1. Create an interaction

ISSUER='https://auth.example.com'
curl --fail --silent --show-error \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'client_id=my-native-client' \
  --data-urlencode 'redirect_uri=com.example.app:/oauth/callback' \
  --data-urlencode 'response_type=code' \
  --data-urlencode 'scope=openid email offline_access' \
  --data-urlencode 'state=OPAQUE_STATE' \
  --data-urlencode 'code_challenge=PKCE_CODE_CHALLENGE' \
  --data-urlencode 'code_challenge_method=S256' \
  --data-urlencode 'nonce=OPAQUE_NONCE' \
  "$ISSUER/oauth2/native/interactions"

The returned interactionId is the server-side authorization request. It carries the client, callback, scopes, resource, state, nonce and PKCE challenge through password and MFA proof.

2. Submit the password

ISSUER='https://auth.example.com' INTERACTION_ID='4H1nKJ2wzY8tqU7sN5x9bR0cP3mL6aDfE2gV1iQoXkM' curl --fail-with-body --silent --show-error \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode "interaction_id=$INTERACTION_ID" \ --data-urlencode 'email=ada@example.com' \ --data-urlencode 'password=correct horse battery staple' \ "$ISSUER/oauth2/native/password"

The endpoint has two successful states.

The primary proof is sufficient

{ "code": "nac_2PM8c2sYV7fR3D...", "state": "af0ifjsldkj", "issuer": "https://auth.example.com", "redirectUri": "com.example.app:/oauth/callback", "replacementRecoveryCode": null }

Continue to code exchange.

MFA is required

{ "status": "mfa_required", "challengeToken": "mf_9vTY2rE7aB..." }

This is 200 OK because the primary credential was valid and the authorization is waiting for its second factor. Keep the challenge token only in memory and show the MFA screen.

Do not reveal whether 401 invalid_credentials was caused by the email, password, account state or upstream proof.

3. Complete MFA

For a six-digit authenticator code:

CHALLENGE_TOKEN='mf_9vTY2rE7aB...' curl --fail-with-body --silent --show-error \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode "interaction_id=$INTERACTION_ID" \ --data-urlencode "challenge_token=$CHALLENGE_TOKEN" \ --data-urlencode 'method=totp' \ --data-urlencode 'code=482193' \ "$ISSUER/oauth2/native/mfa"

For a recovery code, send method=recovery and the complete recovery code. A successful response has the authorization-code shape shown above.

When a recovery code is consumed, replacementRecoveryCode contains its one-time replacement:

{ "code": "nac_2PM8c2sYV7fR3D...", "state": "af0ifjsldkj", "issuer": "https://auth.example.com", "redirectUri": "com.example.app:/oauth/callback", "replacementRecoveryCode": "R7V4-K2MP-9QDX" }

Show the replacement once and require the user to acknowledge it. Never write a recovery code to analytics, logs or crash reports.

4. Validate the authorization result

Before exchanging the code:

  1. compare state with the value created by the app;
  2. require issuer to equal the configured tenant issuer;
  3. require redirectUri to equal the callback registered for this attempt;
  4. atomically mark the interaction result consumed in local state.

Discard the entire flow after any mismatch.

5. Exchange the code

ISSUER='https://auth.example.com'
curl --fail --silent --show-error \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode 'client_id=my-public-client' \
  --data-urlencode 'redirect_uri=com.example.app:/oauth/callback' \
  --data-urlencode 'code=AUTHORIZATION_CODE' \
  --data-urlencode 'code_verifier=PKCE_CODE_VERIFIER' \
  "$ISSUER/oauth2/token"

The token request must use the original PKCE verifier. Store access and refresh tokens in platform secure storage. Validate the ID token’s signature, issuer, audience, expiry and nonce before reading its subject.

Security boundary

Use (iss, sub) as the product account key. A verified email remains mutable profile data and cannot safely identify an existing product record by itself.

Branches the app must support

ResponseApp behavior
400 invalid_interactionclear local flow state and start again
401 invalid_credentialsallow a bounded credential retry without identifying the failed field
409 browser_requiredcontinue authorization in the system browser
409 account_link_requiredstart the explicit browser account-linking path
409 email_verification_requireddirect the user to the verification flow
429 too_many_attemptsdisable submission until Retry-After elapses
503 temporarily_unavailablekeep non-sensitive form state and offer a later retry

A browser continuation must preserve the same authorization intent. It must not turn the native credential route into an embedded web login.

Verify the finished use case

  • invalid credentials never reveal whether an account exists;
  • an MFA-enabled account cannot obtain a code from password proof alone;
  • an incorrect TOTP or recovery code produces no authorization code;
  • a consumed recovery code returns and displays one replacement;
  • modified state, issuer or callback values fail locally;
  • exchanging the code twice produces invalid_grant on the second attempt;
  • the token is accepted only by its intended resource audience.

See native API responses for registration, recovery and the full error table.