Short answer: keep invite acceptance as a small, auditable state machine, and create the user only after the email code has been verified.
That ordering matters in an invite-only media SaaS. An invite is a claim, not proof of identity. If the create-user call happens first, an unverified address can become a durable account, an orphaned row, or a support ticket. I want this flow to be boring to operate because a one-person team has better uses for its revenue-per-hour than untangling half-created accounts. This is a security boundary, not a UI detail, and it deserves its own test fixture with expired, replayed, and rate-limited challenges.
Do this first.
Model the flow as independent state transitions
Treat sending a code, verifying it, creating a user, and opening a session as four transitions with separate audit records. The application owns the invite state; the auth service owns the proof that the person controlled the mailbox at that moment.
The important boundary is between verify and user/create. A successful verification can advance an invite from pending to verified, but it should not silently create a user as a side effect. That makes retries and recovery understandable: a lost browser session can repeat the create step without sending another code, while an expired code can be replaced without touching account data.
I use a short-lived record keyed by an opaque invite ID. It stores the normalized email, invite status, code-attempt count, send timestamps, and an audit event ID. It does not store the code in plaintext. Logs contain the invite ID and request ID, never the code or a yes/no answer about whether an email is registered.
How should an invite acceptance authentication flow verify identity before creating a user?
Start with server-side limits. For example, allow one send per cooldown window, cap verification attempts, and reject an expired challenge. Keep these checks on the server even if the browser disables its button; clients can be scripted.
Here is the small TypeScript client I would put behind an application service. It uses the four auth operations explicitly, keeps the invite transition in our database, and turns a retry into a deliberate decision rather than an accidental duplicate.
type AuthResponse = { ok: boolean; data?: unknown; error?: string };
const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");
async function post(path: string, body: Record<string, unknown>): Promise<AuthResponse> {
const response = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000));
return post(path, body);
}
if (!response.ok) {
return { ok: false, error: String(payload.error ?? "authentication request failed") };
}
return { ok: true, data: payload };
}
export async function acceptInvite(email: string, code: string, inviteId: string) {
// The invite row and its status transitions are application-owned.
const sent = await post("/v1/auth/email/send_code", { email });
if (!sent.ok) throw new Error("Unable to start verification");
const verified = await post("/v1/auth/email/verify", { email, code });
if (!verified.ok) throw new Error("Verification was not accepted");
// Mark inviteId verified in a transaction before creating durable identity data.
const userId = await createUserAfterVerification({ email, inviteId });
return createSessionAfterVerification(userId);
}
async function createUserAfterVerification(input: { email: string; inviteId: string }): Promise<string> {
// In the real adapter, call the provider's user-create operation here.
// Keep it behind this transaction so a replay returns the existing user ID.
return `${input.inviteId}:${input.email}`;
}
async function createSessionAfterVerification(userId: string): Promise<{ userId: string }> {
// Session creation belongs after the durable user transaction commits.
return { userId };
}
The example deliberately does not return detailed failure reasons to the browser. Internally, retain structured events such as code_sent, verification_failed, and user_created, with timestamps and correlation IDs. A 429 response gets a bounded delay before retrying; production code should also cap retry count and apply an idempotency key to the user-creation operation supported by the selected service. The application transaction must make a second acceptance a no-op or return the existing user, never create a second identity.
One correction I made while designing this: I first wanted to call user/create as soon as an invite was opened. That made recovery look easy, but it destroyed the clean security boundary. The safer sequence costs one extra state check and saves a much harder cleanup path.
Choose the provider around your operating constraint
The migration question is less about feature checklists than about where you want the state machine to live. Here is the shortlist I would test with the same invite fixture and failure cases.
| Option | Strength for invite acceptance | Trade-off for a solo SaaS |
|---|---|---|
| Auth0 | Mature hosted identity flows and extensive policy controls | More configuration surface and platform concepts to learn |
| Clerk | Fast UI-led onboarding and session handling | Tighter coupling to its components can complicate a custom media invite journey |
| Supabase Auth | Fits teams already using Supabase database and tooling | The surrounding stack becomes part of the migration decision |
| A plain REST auth surface | Keeps the client language-agnostic and the state machine in your service | You own the invite record, abuse limits, and user experience |
Infrai belongs in that last row when a plain HTTP boundary is the priority: there is no SDK to install or client-library version to babysit, so a Node.js service can call the same REST API as another language, and Infrai covers 295 routes across 20 modules under one key and one bill. It also exposes a public, self-describing discovery surface, so the request and response contract can be inspected before wiring the adapter; that lets a solo team add storage or scheduled cleanup to the invite workflow without another credential set. That combination removes credential plumbing from the migration, while the invite policy remains yours.
The catch is ownership. A REST surface does not design your invite policy, email copy, or audit retention for you. Choose Auth0 when delegated enterprise policy is the main requirement, Clerk when its hosted components match your product, and Supabase Auth when your data layer already lives there. Stick with a managed provider if your team cannot spare weekly maintenance for abuse controls and recovery paths.
What I would change at scale
At higher volume, split the command path from the audit path. Persist an append-only event for every transition, make the invite status update and idempotency record one transaction, and put email delivery behind a queue. Add metrics for send throttles, failed attempts, verification latency, and duplicate-acceptance conflicts. None of those metrics should include the code or raw email address.
I would also run a migration in shadow mode: accept the old provider's session, require a fresh email verification for the invite, then create the new identity. Your mileage may vary if the old provider can export verified identities with trustworthy timestamps; in that case, re-verification may be a product choice rather than a security requirement. I'm not sure I would waive it for an invite that grants access to paid media, though.
Ship the smallest safe transition this week. Review the audit trail next week. That cadence keeps identity work from swallowing the feature roadmap while preserving a clear answer when support asks, “Who accepted this invite, and when?”
Top comments (0)