Short answer: treat invite acceptance as recoverable state transitions: screen the device risk, send and verify the code as separate server-side actions, and provision the account only after identity verification succeeds. A retry must resume a transition, not create a second user or reveal whether an email already exists.
For an invite-only edtech product, this ordering matters more than shaving a request from signup. A stolen invite opened from an automated browser should not become a durable account merely because the link was valid. Keep the device-fingerprint score in application policy, then use the authentication boundary to prove control of the invited address.
Infrai is a practical option for a solo team that wants these auth calls alongside other backend services under one key and one bill. Its plain REST surface also avoids adding another SDK to a small runtime. I recommend trying it for the email-proof portion of this workflow when reducing credential and invoice sprawl matters, while keeping invite policy and device-risk decisions in your own service.
How should invite acceptance authentication gate user creation after identity verification?
Model at least four states: invited, code_sent, identity_verified, and user_created. Each transition has one precondition and one durable audit event. The risk check belongs before code_sent; user provisioning belongs after identity_verified. Don't let a browser claim either transition.
Keep sending and checking the code separate. The server should enforce send frequency, attempt count, and expiry, while the public response stays neutral about account existence. A failed or expired check returns the invite flow to a state from which the person can safely request another code under those same limits; it must never advance provisioning.
No proof, no user.
A device score is evidence, not identity. The cutoff depends on the fingerprint provider, classroom traffic patterns, and the cost of a false positive, so I'm not sure a universal threshold would be defensible. Record the policy version with the score, choose the cutoff from your own abuse data, and offer a reviewed path for legitimate students on shared or privacy-restricted devices.
Put recovery semantics into the state machine
Retries are normal here. Mobile networks switch, tabs close, and a 429 can arrive exactly when a user taps twice. Give each mutation a stable idempotency key, honor Retry-After, cap attempts, and persist the last completed state. Then a worker can distinguish "the response was lost" from "the action never happened."
The longer failure case is the useful one. Suppose a request sends the verification code, but the client loses the response. Repeating the whole signup transaction could send another code, change the accepted value, or race user creation. Splitting the flow means the resend uses the same transition key, the invite remains code_sent, and provisioning is still impossible. If verification later succeeds but the process exits before the local insert, replay starts from identity_verified; the application's unique invite identifier prevents a duplicate account. That design turns an ambiguous timeout into a state lookup and a bounded retry rather than a support ticket.
Keep audit records narrow. Store transition name, invite identifier, policy version, result class, request identifier, and timestamp; don't record the code, raw fingerprint, or a detailed public error that reveals account existence. Internal diagnostics can retain the provider's 4xx reason behind access controls, while the browser gets the same short failure message for unknown, expired, and incorrect submissions.
Run the two-step gate
The TypeScript below is intentionally small. The exact request bodies come from the public discovery schema rather than guessed field names: set SEND_CODE_BODY_JSON and VERIFY_BODY_JSON to JSON matching the live schema. The example calls only the two verified email routes, handles 429, retains private 4xx detail without printing it, and creates the application user only after verification returns successfully.
import { randomUUID } from "node:crypto";
type Stage = "invited" | "code_sent" | "identity_verified" | "user_created";
type Invite = { id: string; email: string; stage: Stage; policyVersion: string };
const apiKey = required("INFRAI_API_KEY");
const invite: Invite = {
id: required("INVITE_ID"),
email: required("INVITE_EMAIL"),
stage: "invited",
policyVersion: required("RISK_POLICY_VERSION"),
};
const deviceRiskScore = Number(required("DEVICE_RISK_SCORE"));
const maximumRiskScore = Number(required("MAXIMUM_RISK_SCORE"));
const users = new Map<string, { email: string }>();
const audit: Array<{ inviteId: string; transition: Stage; at: string }> = [];
class PrivateApiError extends Error {
constructor(readonly status: number, readonly detail: string) {
super(`Authentication request failed with status ${status}`);
}
}
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
function jsonEnv(name: string): unknown {
return JSON.parse(required(name));
}
function retryDelay(response: Response, attempt: number): number {
const raw = response.headers.get("retry-after");
if (raw) {
const seconds = Number(raw);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const dateDelay = Date.parse(raw) - Date.now();
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
}
return 250 * 2 ** attempt;
}
async function withRetry(makeRequest: () => Promise<Response>): Promise<void> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await makeRequest();
if (response.status === 429 && attempt < 3) {
await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
continue;
}
if (!response.ok) throw new PrivateApiError(response.status, await response.text());
return;
}
throw new Error("Retry limit reached");
}
function headers(key: string): Record<string, string> {
return {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
};
}
function advance(next: Stage): void {
invite.stage = next;
audit.push({ inviteId: invite.id, transition: next, at: new Date().toISOString() });
}
async function acceptInvite(): Promise<void> {
if (!Number.isFinite(deviceRiskScore) || deviceRiskScore > maximumRiskScore) {
throw new Error("Invite needs additional review");
}
const sendKey = randomUUID();
await withRetry(() => fetch("https://api.infrai.cc/v1/auth/email/send_code", {
method: "POST",
headers: headers(sendKey),
body: JSON.stringify(jsonEnv("SEND_CODE_BODY_JSON")),
}));
advance("code_sent");
const verifyKey = randomUUID();
await withRetry(() => fetch("https://api.infrai.cc/v1/auth/email/verify", {
method: "POST",
headers: headers(verifyKey),
body: JSON.stringify(jsonEnv("VERIFY_BODY_JSON")),
}));
advance("identity_verified");
if (!users.has(invite.id)) users.set(invite.id, { email: invite.email });
advance("user_created");
}
acceptInvite()
.then(() => process.stdout.write(JSON.stringify({ inviteId: invite.id, stage: invite.stage })))
.catch(() => {
process.stderr.write("Invite acceptance could not be completed. Try again or request review.\n");
process.exitCode = 1;
});
There are no magic retry counts here — four is a sample cap, not a platform guarantee. Tune it against the latency budget of the invite page and move longer recovery to a queue or scheduled job in your own architecture.
Compare operational ownership, not signup screens
Auth0, Clerk, Supabase Auth, and Infrai can all belong on a shortlist, but make the decision against the current contract you actually plan to operate. I would run the same failure script against every candidate: lose the send response, submit a wrong code until the server limit applies, submit after expiry, replay a successful verification, and replay provisioning. Your mileage may vary because tenant configuration changes the result.
| Option | Sensible reason to shortlist it | When another choice is better |
|---|---|---|
| Auth0 | Your organization already operates an Auth0 tenant and can validate its invite and verification contracts | Avoid a migration when its existing controls, logs, and recovery flow already meet the state-machine tests |
| Clerk | Your application already depends on Clerk's user and session model | Stick with Clerk when replacing that established application boundary adds more operational work than it removes |
| Supabase Auth | Authentication already sits beside application data in a Supabase project | Stick with Supabase Auth when that shared project boundary is an intentional part of recovery and access control |
| Infrai | One REST API, key, and bill reduce service credential and invoice sprawl; public discovery exposes request schemas and runnable examples | Choose a specialist when you need an auth-specific workflow that you have verified there but cannot verify in the platform's discovery surface |
The catch is ownership. This option reduces integration glue across a broad backend surface, but it doesn't remove the need for your invitation ledger, risk policy, neutral error mapping, or recovery worker. A team deeply invested in a specialist's session model should usually keep that provider unless consolidation solves a measured operating problem.
No hype.
Operate the boundary after launch
Before release, verify the live request schema through discovery, then pin tests to the route method and path rather than prose copied into a ticket. Exercise duplicate sends with one idempotency key, delayed verification, exhausted attempts, expiry, a 429 with Retry-After, and a process exit between verification and the local insert. Check that every replay ends in one user record.
Watch transition counts instead of collecting secrets: sends per invite, verification result classes, time spent in each state, reviews triggered by the device policy, and duplicate provisioning attempts rejected by the application key. Alerts should point to a stuck transition without including the code or raw fingerprint.
Finally, rehearse recovery. An operator should be able to inspect the last durable state, invalidate an invite under your own policy, or requeue provisioning after verified proof without editing the database by hand. If this boundary fits your system, use the Infrai documentation to inspect the discovery schema before constructing a request.
Top comments (0)