Navigation
View as Markdown

Signup protection

Signup is the awkward action. The user doesn't have an ID yet, so the usual evaluate flow doesn't quite fit — you'd normally pass user to Rupt and your server would later check that user.id on the evaluation matches. On the first signup there's nothing to match against, and a naive integration ends up letting attackers slip past the challenge by closing the tab and reloading your site.

It builds on the generic Challenge flow and adds the binding that makes signup safe. Along with Login protection, it's one of the two foundations every other guide builds on. Fake account, multi-accounting, and the other signup-time guides point back here because the shape is the same.

What this protects against

Skip this pattern and you'll see these problems.

  • A user triggers a challenge, closes the tab, walks to /login. Without server-side state they look like any other new account.
  • The client sends one email to Rupt and a different one to your /register. Your server sees verdict: allow and trusts it.
  • Same email, several signup attempts, none challenge-completed, all of them landing in your DB.
  • Someone grabs the signup success URL after the challenge completed and replays it, hoping to mint a second session off the same evaluation.

The flow below closes them.

The flow

Your server should keep a reference to the evaluation and the status of the signup on the User record. Something like signup_evaluation_id and signup_status on your User model do the trick. We'll fill in the details in the next steps.

Step 1: Call evaluate on the client

On signup you normally don't have a user ID yet. Pass email (and phone if you collect it).

import Rupt from "@ruptjs/client";

const rupt = new Rupt({ clientId: "your_client_id" });

const evaluation = await rupt.evaluate.signup({
  email: form.email,
  // phone: form.phone,
});

// POST /signup to your server with the evaluation ID
await fetch("YOUR_SIGNUP_ENDPOINT/signup", {
  method: "POST",
  body: JSON.stringify({
    ...form,
    evaluation_id: evaluation?.evaluation_id,
  }),
});

Step 2: Handle the verdict on your server

Your server fetches the evaluation, runs the integrity check (the action and email match what you expected), and then handles the verdict.

// POST /signup
const evaluation = await rupt.getEvaluation(evaluation_id);

// Integrity check — block tampering before anything else
if (evaluation.action !== "signup") return reject("Action mismatch");
if (evaluation.user?.email !== form.email) return reject("Identity mismatch");

if (evaluation.verdict === "deny") {
  return reject("Signup denied");
}

if (evaluation.verdict === "allow") {
  const user = await User.create({
    ...form,
    signup_status: "complete",
  });
  return { token: issueToken(user), user };
}

if (evaluation.verdict === "challenge") {
  const user = await User.create({
    ...form,
    signup_status: "await_challenge_completion",
    signup_evaluation_id: evaluation.id,
  });
  return { redirect: evaluation.redirect };
}

The await_challenge_completion user exists in your DB but can't authenticate yet. Two fields cover the binding:

FieldSet whenReset when
signup_evaluation_idOn signup attemptNever!
signup_statusOn signup attempt: complete or await_challenge_completionFlipped to complete after challenge completes

Step 3: Configure the challenge success URL

In the Rupt dashboard, on the relevant policy's Challenge Config (Policies -> Edit -> Challenge Config), set Success URL to the page on your site that handles signup completion. For example: https://yourapp.com/signup/complete.

When the user passes the challenge, Rupt redirects to that URL with the evaluation ID appended:

https://yourapp.com/signup/complete?evaluation=68f…

Configure it once per project. The same URL serves every signup that requires a challenge.

Step 4: Complete the signup server-side

Your /signup/complete page (or route) takes the evaluation ID from the URL and POSTs it to your server. The server is the only thing that should flip the user's status. Consume the evaluation in one shot — consuming is the read and the single-use claim at once, so there's no separate fetch. The first call returns the evaluation; a replay throws 409.

// POST /signup/complete
const { evaluation_id } = req.body;

// Cross-check against the user we created at /signup
const user = await User.findOne({
  signup_evaluation_id: evaluation_id,
  signup_status: "await_challenge_completion",
});
if (!user) return reject("No pending signup matches this evaluation");

// One shot: consume reads and claims the evaluation atomically. The first call
// wins; a replay throws 409.
let evaluation;
try {
  evaluation = await rupt.consumeEvaluation(evaluation_id);
} catch (err) {
  if (err.status === 409)
    return reject("This signup link was already used. Please log in.");
  // Rupt unreachable: fail open. The status flip below still blocks replays.
}

if (evaluation && evaluation.challenge?.status !== "completed") {
  return reject("Challenge not completed");
}

user.signup_status = "complete";
await user.save();
return { token: issueToken(user), user };

The lookup is by signup_evaluation_id, not by trusting the ID in the query string. A user URL-tampering with someone else's evaluation= won't find a pending row bound to themselves. And because the flip from await_challenge_completion to complete is itself single-use, consuming is a second gate: even if Rupt is down when you try to consume, a replay still finds no pending row.

Step 5: Refuse pending users at login

This is the bypass-prevention check. A user still in await_challenge_completion who skips /signup/complete and goes straight to /login gets rejected.

/** PSEUDO CODE **/
// POST /login
const user = await User.findOne({ email: form.email });
if (!user) return reject("Invalid credentials");

if (user.signup_status === "await_challenge_completion") {
  const evaluation = await rupt.getEvaluation(user.signup_evaluation_id);
  return reject("Complete your signup challenge first.", {
    redirect: evaluation.redirect,
  });
}
// …password check, evaluate.login integrity check, etc.