Sign-up and verification
Why sign-up always returns a challenge, and the one branch that handles every mode.
signUp never signs someone in on its own. It returns a challenge — the
address or phone has to be confirmed first — and what that challenge looks like
depends on how the environment is configured.
The point of this page is that you write the branch once and never touch it again when that configuration changes.
const { data, error } = await nulvless.auth.signUp({ email, password });
if (error) return;
if (data.challenge?.preferred === 'link') {
// Nothing to collect — a link was emailed.
}
if (data.challenge) {
// A code was sent. Collect it.
}What decides which one
One environment setting, Registration → Confirmation type:
otpA code is sent. The account is created when the code is verified.linkA link is emailed. Requires an email address.A second setting, Authenticate after registration, decides whether the account — once confirmed — also produces a session.
confirmationType: 'otp'
signUp returns a challenge with subject: 'sign_up' and preferred set to
email_otp when the user has an email, sms_otp otherwise.
const { data } = await nulvless.auth.signUp({ email, password });
if (data.challenge) {
// Collect the code, then:
const { data: done } = await nulvless.auth.verifyChallenge({
token: data.challenge.token,
code,
});
}The account does not exist yet
On this path the user is not created by signUp. The submitted details are
carried on the challenge and the account is created when the code is verified.
Two consequences worth designing for: an abandoned sign-up leaves nothing behind, and the email is not taken until confirmation — so two people can start a sign-up with the same address, and the second one to confirm will fail.
After a successful verifyChallenge, authenticateAfterRegistering applies
again: you get a session, or just the user.
confirmationType: 'link'
The challenge comes back with preferred: 'link', which means there is
nothing to collect. Show a "check your email" screen instead of a code input:
if (data.challenge?.preferred === 'link') {
setCheckYourEmail(true);
return;
}The link lands on the URL configured as confirmAccountLinkUri, carrying a
token. Your page finishes it:
const token = new URLSearchParams(location.search).get('token');
await nulvless.auth.activate(token);activate returns no session. Confirming through a link proves the address; it
does not sign the person in. Send them to sign-in afterwards.
This path requires an email address — a link environment with a phone-only
sign-up is rejected.
One component for every code
Because the challenge carries subject, the same screen serves sign-up, OTP
sign-in, MFA and sudo:
function VerifyCode({ challenge }: { challenge: Challenge }) {
return (
<form onSubmit={submit}>
<p>We sent a code to {challenge.identifier}</p>
<input value={code} onChange={(e) => setCode(e.target.value)} inputMode="numeric" />
</form>
);
}challenge.identifier arrives masked — u***@example.com — so it is safe to
render. expires_in is there for a countdown.
What can reject a sign-up
Verified against the platform's own checks, in the order they run:
Registration closed403Allow new users to sign up is off for this environment. Carries a message, no code.
Captcha400captcha_required when the token is missing, captcha_verification_failed
when the provider rejects it. Enabled per environment for sign-ups.
No email or phone400One of the two is always required. Message only, no code.
Missing required credential400Required credentials can demand email, phone, or both. Message only — see the note below.
Email domain blocked403The address failed the environment allowlist. Message only, no code.
Password rejected400password_weak for length and character requirements; password_leaked when
breach checking is on and the password is known. Two different codes, not one.
Already exists422email_already_exists, phone_already_exists or username_already_exists —
but only when exactly one field collides. When two collide at once the
response carries the message and no code.
"Missing required credential" computes user_missing_email and
user_missing_phone internally and then throws without them, so neither code
reaches you. Branch on the 400 and your own knowledge of Required credentials
rather than waiting for a code that is not sent.
username alone is not enough
SignUpParams accepts username, but sign-up always requires an email or a
phone as well. A username-only sign-up is rejected.
Sign-ups that send something — the otp and link paths — are also rate
limited, under the same bucket as other verification sends.
Password rules are the environment's, not yours
The password is checked against the Passwords rules: a minimum length, character requirements, and optionally a check against known-breached passwords. Do not re-implement those rules in your form — mirror them in the hint text if you like, but let the platform be the one that rejects, so the two can never disagree.
Writing the branch once
The whole point is that this code survives an environment being reconfigured
from otp to link and back:
const { data, error } = await nulvless.auth.signUp({ email, password, profile });
if (error) return showError(error);
if (data.challenge?.preferred === 'link') return setCheckYourEmail(true);
if (data.challenge) return router.push('/verify', { state: { challenge: data.challenge } });
return router.push('/sign-in');Three lines, and nothing about them mentions which mode the environment is in.