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:
- compare
statewith the value created by the app; - require
issuerto equal the configured tenant issuer; - require
redirectUrito equal the callback registered for this attempt; - 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.
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
| Response | App behavior |
|---|---|
400 invalid_interaction | clear local flow state and start again |
401 invalid_credentials | allow a bounded credential retry without identifying the failed field |
409 browser_required | continue authorization in the system browser |
409 account_link_required | start the explicit browser account-linking path |
409 email_verification_required | direct the user to the verification flow |
429 too_many_attempts | disable submission until Retry-After elapses |
503 temporarily_unavailable | keep 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_granton 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.