Documentation

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.

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 closed403

Allow new users to sign up is off for this environment. Carries a message, no code.

Captcha400

captcha_required when the token is missing, captcha_verification_failed when the provider rejects it. Enabled per environment for sign-ups.

No email or phone400

One of the two is always required. Message only, no code.

Missing required credential400

Required credentials can demand email, phone, or both. Message only — see the note below.

Email domain blocked403

The address failed the environment allowlist. Message only, no code.

Password rejected400

password_weak for length and character requirements; password_leaked when breach checking is on and the password is known. Two different codes, not one.

Already exists422

email_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.

Next

On this page