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 | nullRequiredShort-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.
preferredChallengeSubjectRequiredWhich 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.linkA link was emailed. There is nothing to collect — show "check your email" instead of a code input.
factorsAuthMethod[]OptionalThe 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.
identifierstringOptionalAlready 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.
subjectChallengeSubjectRequiredWhy 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_innumberOptionalMilliseconds 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,
});tokenstringRequiredFrom the Challenge object.
codestringRequiredWhatever 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);tokenstringRequiredThe 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);tokenstringRequiredFrom the challenge currently in flight.
typeMFAMethodRequiredThe 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_idstringOptionalsms_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
verifywill 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
totporrecovery_codessends 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' });typeMFAMethodRequiredWhich method to challenge with.
factor_idstringOptionalsms_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.