Documentation
ReferenceJavaScript SDKNamespacesauth.challenges

auth.challenges

The step between "credentials accepted" and "session issued".

A challenge is what comes back when the platform needs one more thing before issuing a session — an OTP, a TOTP code, a sudo confirmation. You rarely create one yourself: signIn, signUp and sessions.sudo() return one when the environment or the user's factors require it.

Every challenge is short-lived, single-purpose, and limited to four attempts. On the fourth wrong code the challenge is closed on the server and further verification fails with action_verification_failed — the user has to start the flow again rather than keep guessing.

The shape

tokenstring | nullRequired

Short-lived, and the only handle you have on the challenge. Carry it to verify(). It is not the session token and cannot be used as one. null when preferred is link: there is no code to verify.

preferredChallengeSubjectRequired

Which method to present first. The platform picks it — you render it.

totpAn authenticator app. Chosen whenever the user has one.
sms_otpA code sent by SMS.
email_otpA code sent by email.
recovery_codesOne of the codes saved at enrolment.
link

A link was emailed. There is nothing to collect — show "check your email" instead of a code input.

factorsAuthMethod[]Optional

The alternatives this user actually holds. This is what a "try another way" link is built from — do not offer methods the user has not enrolled.

identifierstringOptional

Already masked by the platform — u***@example.com. Safe to render as-is, and the reason you do not need the user's real address on the verification screen.

subjectChallengeSubjectRequired

Why the challenge was issued.

sign_upConfirming a new account.
verify_pending_userConfirming an account that was never activated.
sign_in_with_otpPasswordless sign-in.
mfaA second factor after a password.
sudoRe-confirming identity for a sensitive action.
add_emailAdding an email identity to an existing account.
add_phoneAdding a phone identity.
expires_innumberOptional

Milliseconds until the challenge expires. Configured per environment and per method, so do not hard-code a countdown.

subject is what lets one screen serve every flow: the same code input can finish a sign-up, an MFA step or a sudo elevation, and subject tells you which copy to show and where to send the user afterwards.


verify

verify({ token, code }) → Authenticated

Submits the code the user entered. This is the call that ends the flow.

const { data, error } = await nulvless.auth.challenges.verify({
  token: challenge.token,
  code,
});
tokenstringRequired

From the Challenge object.

codestringRequired

Whatever the user typed. A recovery code goes in this same field — there is no separate call for it, and the platform decides which factor to check against from the challenge itself.

Returns Authenticated — the same shape signIn returns. On success the SDK stores the session automatically, so a successful verify leaves the client signed in with no further work.

A wrong code counts against the four-attempt budget. The fourth failure closes the challenge, and the error changes from a plain rejection to action_verification_failed. Distinguish the two in your UI: the first tells the user to retype, the last tells them to start over.

auth.verifyChallenge is the same operation on the top-level client. Use whichever reads better where you are.


get

get(token) → Challenge

Reads the current state of a challenge without consuming an attempt.

const { data: challenge } = await nulvless.auth.challenges.get(token);
tokenstringRequired

The challenge token.

The case this exists for: the user reloads the verification page, or opens the emailed link on a different device. You have the token in the URL but no idea whether the challenge is still open. Reading it first lets you show an expired state instead of collecting a code that cannot work.


change

change({ token, type, factor_id }) → { challenge }

Switches a challenge already in flight to a different method, without restarting the sign-in.

const { data } = await nulvless.auth.challenges.change({
  token: challenge.token,
  type: 'email_otp',
});

// The new challenge replaces the old one.
setChallenge(data.challenge);
tokenstringRequired

From the challenge currently in flight.

typeMFAMethodRequired

The method to move to. Note the field is type, not method.

totpNothing is sent — the app already generates codes.
sms_otpSends a new code by SMS.
email_otpSends a new code by email.
recovery_codesNothing is sent.
factor_idstringOptional

sms_otp only. Which SMS factor receives the code — an id from challenge.factors. Defaults to the oldest SMS factor.

This is what powers "try another way" — a user whose authenticator is unavailable moves to an email code without going back to the password screen.

Two things to handle:

  • A new challenge comes back, with a new token. Replace the one you were holding, or the next verify will fail against a challenge that no longer matters.
  • Switching to an OTP method sends a message, so it is rate limited under the same budget as any other verification send. Switching to totp or recovery_codes sends nothing and is not limited.

Offer only the methods listed in challenge.factors.


create

create({ type, factor_id }) → { challenge }

Starts a challenge directly, for a method you name.

const { data } = await nulvless.auth.challenges.create({ type: 'totp' });
typeMFAMethodRequired

Which method to challenge with.

factor_idstringOptional

sms_otp only. Which SMS factor receives the code — an id from factors.list(). Defaults to the oldest SMS factor.

A totp challenge accepts a code from any of the user's authenticator apps. For sms_otp, pass factor_id to choose the phone:

const { data } = await nulvless.auth.challenges.create({ type: 'sms_otp', factor_id });

Unlike the rest of this namespace, create requires an authenticated session — it challenges the current user. It is not part of sign-in, where no session exists yet.

You need this far less often than it looks. Sign-in, sign-up and sudo already return a challenge; reach for create when your own product wants to re-confirm someone mid-session for a reason the platform does not know about.

link is not valid here — it is a preferred value the platform chooses during sign-up, not a method you can request.

Next

On this page