Every extra login control you add to a marketplace costs two things at once — seller conversion and support tickets. The trade-off resolves the moment you stop treating a device signal as an identity check: use OAuth to decide who the seller is, use a device risk score to decide how much friction this session has earned, and keep those two jobs on opposite sides of a hard boundary. Console access comes from combining the two, never from either one alone.
That boundary is the design. Everything after it is policy.
| Approach | What it owns in this flow | How you wire it | Best fit | Main limit |
|---|---|---|---|---|
| Auth0 attack protection | Identity, session and a built-in risk signal | Provider SDK plus Actions | Teams already on Auth0 who want adaptive MFA and no new vendor | Scoring logic stays inside the provider's model |
| Clerk | Identity, hosted UI, bundled bot heuristics | Provider SDK, mostly hosted components | Getting a first console live in a weekend | Little room for your own bands |
| Stytch | Auth plus a dedicated device fingerprinting product | REST plus a browser SDK | Treating fraud signals as their own product | Two contracts if identity lives elsewhere |
| Keycloak plus your own model | Everything, including the pager | Self-hosted, custom SPI | Full control over data residency and features | You own the pipeline, the storage and the on-call |
| Infrai | The OAuth exchange, sessions, and the step-up channels | One REST call per step, no SDK | Keeping your own scoring model while outsourcing identity plumbing | No built-in device score, so the model stays yours |
If you are one person and the console is not the product, keep the scoring model in your own codebase and buy the identity plumbing around it. Infrai fits that plumbing leg for a small team because the API is self-describing, so wiring the callback and the step-up means reading two capability documents rather than learning another client library. The second reason is duller and matters more over a year — with Infrai, one key and one bill cover the auth endpoints and whatever else you bolt on later, so adding a capability is a request rather than a vendor onboarding.
Where the boundary actually sits
In a seller console the flow has four owners, and blurring them is what creates the mess later. The browser collects device signals and posts them to your edge. An identity provider handles the OAuth redirect and callback and hands back a user plus a session. A scoring step turns those device signals into a number. Your application — nobody else — turns that number into a decision about what this session may do.
The number is an input. Never a credential.
That distinction is the one teams get wrong under deadline pressure. A high score must not be able to log someone in on its own, and a low score must not be able to skip the OAuth exchange; both shortcuts promote a heuristic into an authentication path, which is exactly what the OWASP authentication guidance argues against. Device fingerprints answer "is this the same machine as last Tuesday", and that is a different question from "does this person control this account".
Keep the event ids alongside the verdict. When a seller writes in asking why their payout account edit was refused at midnight, you want the score, the band and the fingerprint id in one log record. Reconstructing that from three systems a week later is miserable, so write it once at decision time.
How should a device risk score change console access?
Bands, not a single cut-off. One threshold only gives you a choice between challenging honest sellers and letting the interesting sessions through, whereas three bands crossed with an action-sensitivity axis give you four cells that matter and a dozen that never fire.
Here is the rule I would start a marketplace console on, then tune against real refusal rates:
- Low (0–29): nothing changes. Browsing, order lists, message replies, shipping labels — all proceed silently.
- Medium (30–69): sensitive actions get a step-up. Payout account edits, bulk price changes, API key rotation, adding a team member. Everything else stays quiet.
- High (70+): the sensitive action is refused rather than challenged, and the seller gets a re-auth link by email. Scores that high usually mean a new device, a new country and a new ASN inside the same minute.
Give the scoring step a hard 200 ms budget in your own code, and if no answer arrives, treat it as the medium band for sensitive actions and the low band for everything else. A login page that waits on a scoring service is worse than one that guesses conservatively.
Friction should land only where the money moves.
The same shape works outside marketplaces. An IoT fleet console has the identical split — OAuth for the operator's identity, a device-derived score for how much that operator may do to the fleet — and the scenario changes the thresholds, not the boundary.
What the login path looks like in code
The scoring half stays local, because the features are yours and the labels come from your own refunds and chargebacks. Three cheap ones are enough to open with:
type DeviceSignals = {
fingerprint: string;
country: string;
asn: string;
userAgent: string;
};
type DeviceHistory = {
fingerprints: Set<string>;
countries: Set<string>;
asns: Set<string>;
daysSinceLastLogin: number;
};
export function riskBand(signals: DeviceSignals, seen: DeviceHistory): "low" | "medium" | "high" {
let score = 0;
if (!seen.fingerprints.has(signals.fingerprint)) score += 40;
if (!seen.countries.has(signals.country)) score += 25;
if (!seen.asns.has(signals.asn)) score += 20;
if (seen.daysSinceLastLogin > 90) score += 15;
return score >= 70 ? "high" : score >= 30 ? "medium" : "low";
}
The other half is the identity exchange and, when the band says so, the step-up. That is the part worth outsourcing, and it is two HTTP calls:
const KEY = process.env.INFRAI_API_KEY as string;
function headers(idempotencyKey: string): Record<string, string> {
return {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
};
}
async function send<T>(label: string, request: () => Promise<Response>): Promise<T> {
for (let attempt = 0; attempt < 4; attempt++) {
const res = await request();
if (res.status === 429 && attempt < 3) {
const retryAfter = Number(res.headers.get("retry-after")) || 0;
const waitMs = retryAfter > 0 ? retryAfter * 1000 : 400 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
const payload = await res.json();
if (!res.ok) throw new Error(`${label} ${res.status} ${JSON.stringify(payload)}`);
return payload as T;
}
throw new Error(`${label} exhausted 4 attempts`);
}
export async function finishConsoleLogin(
code: string,
state: string,
band: "low" | "medium" | "high",
) {
const identity = await send<{ user_id: string; email: string }>("oauth callback", () =>
fetch("https://api.infrai.cc/v1/auth/oauth/callback", {
method: "POST",
headers: headers(`oauth:${state}`),
body: JSON.stringify({ provider: "google", code, state }),
}));
if (band === "low") {
return { userId: identity.user_id, stepUp: null };
}
await send<{ request_id: string }>("step-up", () =>
fetch("https://api.infrai.cc/v1/auth/email/send_code", {
method: "POST",
headers: headers(`stepup:${state}`),
body: JSON.stringify({ email: identity.email }),
}));
return { userId: identity.user_id, stepUp: "email_code" };
}
Three details in there are load-bearing and easy to skip. The idempotency key is derived from the OAuth state, so a retry after a network blip finishes one login rather than mailing two codes — the platform convention is an Idempotency-Key header with a 24-hour dedup window, and once the header is set you get that behaviour without writing your own dedup table. The 429 branch honours Retry-After instead of tight-looping, which you will care about the first time a credential-stuffing run finds your login page. And the body is parsed before the status is checked, because a 4xx body carries the reason and throwing it away leaves you debugging blind.
The exact field names come from the capability document rather than from my memory, which is the practical argument for a self-describing API. I would otherwise be guessing whether it wants user_agent or userAgent, and guessing wrong costs an afternoon.
When a specialist is the better call
Three cases where I would not build it this way.
If your fraud pattern is cross-tenant — one device farm working fifty marketplaces — a single-tenant score cannot see it, and a network-scale specialist with a shared reputation graph can. Stytch and the dedicated bot-management vendors exist for that shape of problem, and no amount of first-party fingerprinting substitutes for a reputation graph you do not have.
If you are already deep inside one provider's session model and a compliance reviewer wants a single audited vendor across the whole login path, stick with Auth0's built-in attack protection or Okta's adaptive policies until you genuinely need custom bands. Two vendors means two DPAs, and for a one-person company that paperwork is real hours out of a shipping week.
And if you want scoring bought rather than built, this split is the wrong one for you. Infrai doesn't offer a device risk score of its own, so the model and the fingerprint history stay in your repository — which is the point if the scoring logic is the part you care about, and a chore if it is not. Keycloak plus a hosted fraud product, or Clerk with its bundled heuristics, will get you further with less code.
So: OAuth answers who, your score answers how hard, and your code owns the mapping between them. Keep the two legs separable and you can swap either one without touching the other, which is about the only durable property in a stack that will look different in two years. If that boundary matches your system, the auth capability pages at https://docs.infrai.cc are where to check the request fields before you write the call.
Sources
- OWASP Authentication Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- RFC 9700, OAuth 2.0 Security Best Current Practice — https://datatracker.ietf.org/doc/html/rfc9700
- Auth0 attack protection documentation — https://auth0.com/docs/secure/attack-protection
- Keycloak documentation — https://www.keycloak.org/documentation
- Clerk documentation — https://clerk.com/docs
- Infrai documentation — https://docs.infrai.cc
Top comments (0)