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.
The flow
Step 1: Call evaluate at login
Pass the user id and email (and phone if you have it).
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,
}),
});
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.
// 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.
// 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) };
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 and you've covered both ends of authentication. Every other guide builds on one of the two.
- Need help? Contact support.
- Want to see Rupt in action? Request a demo.
- Questions? Talk to sales.
- Check out our changelog.
- Check our status page.
- LLM? Read llms.txt.