---
title: Signup protection
---

# 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](/docs/v3/challenge-flow) and adds the binding that makes signup safe. Along with [Login protection](/docs/v3/fundamentals/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

<!-- prettier-ignore-start -->
::MermaidDiagram
---
code: |
  sequenceDiagram
    actor U as User
    participant C as Signup form
    participant S as Your server
    participant R as Rupt
    participant X as Challenge UI

    U->>C: Submits signup form (email, password)
    C->>R: evaluate.signup({ email })
    R-->>C: { evaluation_id, redirect? }
    C->>S: POST /signup { form fields, evaluation_id }
    S->>R: GET /v3/evaluations/{evaluation_id}
    R-->>S: { verdict, user, challenge }
    Note over S: Integrity check (action, email, verdict)

    alt verdict = deny
      S-->>C: Reject (401)
      C-->>U: Show error
    else verdict = allow
      S->>S: Create user, signup_status = allowed
      S-->>C: { token, user }
      C-->>U: Logged in
    else verdict = challenge
      S->>S: Create user, signup_status = await_challenge_completion
      S-->>C: { redirect }
      C-->>U: Navigate to challenge URL
      U->>X: Complete challenge
      X->>S: Redirect to success_url?evaluation=…
      S->>R: POST /v3/evaluations/{evaluation_id}/consume
      R-->>S: { challenge.status, user } — or 409 if already used
      Note over S: One shot — consume reads and claims at once,<br/>confirm it matches the pending user and the challenge completed
      S->>S: Flip user.signup_status = complete
      S-->>U: Issue token, navigate in
    end
---
::
<!-- prettier-ignore-end -->

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

::ClientPlatform

#web

```js
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,
  }),
});
```

#ios

```swift
let response = try await rupt.evaluate(
  action: "signup",
  email: form.email
)

// POST evaluation.evaluationId to your server with the form
```

#android

```kotlin
val response = rupt.evaluate(
  action = "signup",
  email = form.email,
)

// POST response.evaluationId to your server with the form
```

::

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

```ts
// 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:

| Field                  | Set when                                                      | Reset when                                      |
| ---------------------- | ------------------------------------------------------------- | ----------------------------------------------- |
| `signup_evaluation_id` | On signup attempt                                             | Never!                                          |
| `signup_status`        | On signup attempt: `complete` or `await_challenge_completion` | Flipped 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`.

```ts
// 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.

```ts
/** 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.
```
