---
title: Login protection
---

# Login protection

The login evaluation has three verdicts: `allow`, `deny`, and `challenge`. The `allow` and `deny` verdicts are simple cases.

The `challenge` verdict requires a bit more work to ensure it's not bypassed. The one rule is: **don't issue a session or token while a challenge is outstanding.** If the verdict is `challenge`, the user gets nothing until your server confirms the challenge completed.

Login and signup protection are the foundation of every other guide. Once you have those two down, you can build on them with different policies and checks for any other use case.

<!-- ## What this protects against

- A user triggers a login challenge, closes the tab, and hits `/login` again. If a challenge never blocks the session, the second attempt just works.
- The client sends one user to Rupt and authenticates as a different one. Your server sees `verdict: allow` and trusts it.
- A stolen-credential login that should have been challenged sails through because the server never confirmed the outcome.
- An attacker replays the post-challenge success URL, or guesses an `evaluation_id` whose challenge already completed, to mint a session without passing a challenge of their own.
- An attacker opens the browser console, calls `rupt.evaluate.login({ email })` with someone else's address, and posts the `evaluation_id` it returns straight to your completion route, skipping the password entirely.

That last one comes up a lot, so it's worth being explicit. `evaluate.login` runs with your publishable client ID, so anyone can open the console, call it with any email, and get back a real `evaluation_id`. That's expected and it doesn't get them anywhere, for two reasons covered in [step 4](#step-4-consume-the-evaluation-and-start-the-session): an evaluation they minted for themselves comes back `allow` or `deny`, which your completion route rejects outright, and if it does come back `challenge`, completing it means entering a code Rupt sent to the account's own email or phone. Guessing evaluation IDs doesn't help either, since consuming one is single-use. -->

## The flow

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

    U->>C: Submits login form (email, password)
    C->>R: evaluate.login({ user, email })
    R-->>C: { evaluation_id, redirect? }
    C->>S: POST /login { credentials, evaluation_id }
    S->>R: GET /v3/evaluations/{evaluation_id}
    R-->>S: { verdict, user, challenge }
    Note over S: Check password, run integrity check

    alt verdict = deny
      S-->>C: Reject (401)
      C-->>U: Show error
    else verdict = allow
      S->>S: Start a session
      S-->>U: Logged in
    else verdict = challenge
      S-->>C: { redirect } (no session yet)
      C-->>U: Navigate to challenge URL
      U->>X: Complete challenge (code sent to the account's email or phone)
      X->>S: Redirect to success_url?evaluation=…
      S->>R: POST /v3/evaluations/{evaluation_id}/consume
      R-->>S: { verdict, challenge.status } or 409 if already used
      Note over S: Confirm the consume succeeded,<br/>verdict = challenge, and challenge.status = completed
      S->>S: Start a session
      S-->>U: Logged in
    end
---
::
<!-- prettier-ignore-end -->

## Step 1: Call evaluate at login

Pass the `user` id and `email` (and `phone` if you have it).

::ClientPlatform

#web

```js
import Rupt from "@ruptjs/client";

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

const loginEval = await rupt.evaluate.login({
  user: user.id,
  email: form.email,
});

// POST /login to your server with the credentials and the evaluation ID
await fetch("/login", {
  method: "POST",
  body: JSON.stringify({
    ...credentials,
    evaluation_id: loginEval?.evaluation_id,
  }),
});
```

#ios

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

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

#android

```kotlin
val response = rupt.evaluate(
  action = "login",
  user = user.id,
  email = form.email,
)

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

#react-native

```ts
const loginEval = await rupt.evaluate.login({
  user: user.id,
  email: form.email,
});

// POST /login to your server with the credentials and the evaluation ID
await fetch("/login", {
  method: "POST",
  body: JSON.stringify({
    ...credentials,
    evaluation_id: loginEval?.evaluation_id,
  }),
});
```

::

## Step 2: Handle the verdict on your server

Your server checks the password, fetches the evaluation, runs the integrity check (the action and user match what you expected), then branches on the verdict. On a challenge it issues nothing and hands back the redirect.

```ts
// POST /login
if (!checkPassword(credentials)) return reject("Invalid credentials");

let evaluation;
try {
  evaluation = await rupt.getEvaluation(evaluation_id);
} catch (err) {
  // Any other error. The password already checked out, so sign them in
  // rather than lock everyone out. Log and alert on this.
  return { session: startSession(user) };
}

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

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

if (evaluation.verdict === "allow") {
  return { session: startSession(user) };
}

if (evaluation.verdict === "challenge") {
  // Don't start a session. Send the user to the challenge first.
  return { redirect: evaluation.redirect };
}
```

## Step 3: Configure the challenge success URL

In the Rupt dashboard, on the relevant Challenge Config (`Policies -> Edit -> Challenge Config`), set **Success URL** to the page that finishes login. For example: `https://yourapp.com/login/complete`.

When the user passes, Rupt redirects there with the evaluation ID appended:

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

## Step 4: Consume the evaluation and start the session

Your `/login/complete` route takes the evaluation ID from the URL and **consumes** it. Consuming is a single-use, atomic claim: Rupt marks the evaluation spent and returns it in one step, so the same success URL can never start a second session. The first call wins; a replay throws `409`.

This route is only ever reached from a challenge redirect, so treat it that way. Start the session only if the consume succeeds, the verdict was `challenge`, and that challenge completed.

```ts
// POST /login/complete
const { evaluation_id } = req.body;

let evaluation;
try {
  // Single-use: the first call wins, a replay throws 409.
  evaluation = await rupt.consumeEvaluation(evaluation_id);
} catch (err) {
  if (err.status === 409) return reject("This login link was already used");
  // Any other error. This route has no password to fall back on, so send
  // them back to /login, which does and fails open there.
  return { redirect: "/login" };
}

if (evaluation.action !== "login") return reject("Action mismatch");

// This route exists to finish a challenge. Anything else never should have
// gotten here: `allow` logins finish at /login, and `deny` never finishes.
if (evaluation.verdict !== "challenge") return reject("Unexpected verdict");

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

return { session: startSession(evaluation.user) };
```

::alert{type="info"}
Rejecting non-`challenge` verdicts is currently your job. We're considering having the consume endpoint refuse them outright, so the check may become redundant later. Adding it now costs nothing either way.
::

### What each case does

| What arrives at `/login/complete`                      | What happens                                                                               |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| Verdict `challenge`, status `completed`, first consume | Session starts. This is the only path through.                                             |
| Verdict `challenge`, status anything else              | Rejected. The user never passed the challenge.                                             |
| Verdict `allow` or `deny`                              | Rejected. An `allow` login already finished at `/login`, so reaching here means tampering. |
| An `evaluation_id` that was already consumed           | Rejected with `409`. A captured success URL is worth nothing on the second use.            |
| A guessed or console-minted `evaluation_id`            | Rejected by one of the rows above.                                                         |
| Any other error                                        | Sent back to `/login`, which fails open and signs them in.                                 |

If, for whatever reason, an error occurs during the evaluation process, send the user back to `/login` to start over. A network blip, a timeout, or downtime on either side should never stop your users logging in, but `/login/complete` is the wrong place to fail open: all it holds is an evaluation ID, so "let them through" there means letting anyone through as anyone. Bounce them to `/login` instead, where the password check runs and failing open is safe.

The session starts here, never at `/login` when a challenge was issued.

---

Pair this with [Signup protection](/docs/v3/fundamentals/signup-protection) and you've covered both ends of authentication. Every other guide builds on one of the two.
