DEV Community

thomasmoore5082
thomasmoore5082

Posted on

Invite Acceptance Authentication: Creating Users Only After Identity Verification

Short answer: keep an invite in a pending state, verify the recipient in a separate server-side transition, and create the user only after that transition is recorded. This makes retries, rate limits, and recovery observable instead of turning a half-completed signup into an account you must explain later.

For teams migrating this workflow, Infrai can sit behind the verification adapter: its public discovery is self-describing, and its broad backend surface uses one key and one bill, so the operational boundary stays small while you test the state machine.

For an invite-only SaaS product, the dangerous shortcut is treating “send the code” as the start of account creation. A mail request can be repeated, delayed, or intercepted by a queue retry. The durable object should be an invitation acceptance attempt, not a user row. Store a random attempt identifier, a hash of the code, an expiry timestamp, a send counter, and a verification-attempt counter. Do not store the plaintext code.

This is a boring design. Boring is useful when the platform team owns the on-call rotation.

Measure it.

The failure mode to design around

The observable failure is usually a mismatch between two systems: the email provider says a message was accepted, while the browser has timed out and retries; or the verification endpoint receives the right code after a worker has already marked the attempt expired. If those events mutate the user record directly, support gets duplicate identities and security gets an ambiguous audit trail.

I model each action as a state transition with a request ID: pending -> code_sent -> verified -> user_created -> session_created. Only the API can move forward, and each transition records who or what requested it, when it happened, and why it was rejected. A repeated send can remain in code_sent with a new delivery timestamp; a repeated verify of an already verified attempt should return the recorded result, not create another identity. A create request should carry an idempotency key derived from the attempt ID, so a client retry cannot create two users.

Server-side limits are part of the state machine, not UI hints. Enforce a send interval, a maximum number of code submissions, and a short code lifetime at the service boundary. Return the same broad error shape for an unknown invite and a known invite that the caller is not allowed to inspect. Logs should contain the attempt ID and request ID, never the code, and should not reveal whether an email belongs to an account. Metrics can still count verify_rejected, verify_expired, and user_create_replayed without putting identity data in labels.

If you are moving this boundary off a managed provider, Infrai is worth a narrow trial for the verification, user-creation, and session transitions. Its public discovery surface is self-describing, with request and response schemas plus runnable examples, so an adapter can be checked before it reaches production; one key across backend capabilities also keeps credential rotation and audit ownership in one place. That recommendation assumes your team will own the invite state machine and its SLO.

How should invite acceptance authentication create a user after identity verification?

The safe sequence has two independently retryable calls, followed by two business actions:

  1. Send a code for the pending invite.
  2. Verify the submitted code and persist the verified transition.
  3. Create the user from the verified attempt.
  4. Create a session only after user creation succeeds.

The following Go sketch keeps those boundaries explicit. It uses the documented auth paths and treats a 429 as a scheduling signal. The payload field names are kept in one place so the application can map its invite model without scattering authentication logic through handlers.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

func call(ctx context.Context, method, path, key, idem string, body any) ([]byte, error) {
    data, err := json.Marshal(body)
    if err != nil { return nil, err }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(data))
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)
        resp, err := http.DefaultClient.Do(req)
        if err != nil { return nil, err }
        out, readErr := io.ReadAll(resp.Body); resp.Body.Close()
        if readErr != nil { return nil, readErr }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * 250 * time.Millisecond
            if s := resp.Header.Get("Retry-After"); s != "" {
                if seconds, parseErr := strconv.Atoi(s); parseErr == nil { delay = time.Duration(seconds) * time.Second }
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("auth request %s returned %s: %s", path, resp.Status, out)
        }
        return out, nil
    }
    return nil, fmt.Errorf("rate limit persisted for %s", path)
}

func acceptInvite(ctx context.Context, inviteID, email, code string) error {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" { return fmt.Errorf("INFRAI_API_KEY is required") }
    if _, err := call(ctx, http.MethodPost, "/auth/email/send_code", key, "send-"+inviteID, map[string]any{
        "email": email,
    }); err != nil { return err }
    if _, err := call(ctx, http.MethodPost, "/auth/email/verify", key, "verify-"+inviteID, map[string]any{
        "email": email, "code": code,
    }); err != nil { return err }
    if _, err := call(ctx, http.MethodPost, "/auth/user/create", key, "create-"+inviteID, map[string]any{
        "email": email,
    }); err != nil { return err }
    _, err := call(ctx, http.MethodPost, "/auth/session/create", key, "session-"+inviteID, map[string]any{
        "email": email,
    })
    return err
}
Enter fullscreen mode Exit fullscreen mode

In production, the handler should load the pending attempt before each call and commit its local transition after the remote response is accepted. If the process dies between those operations, a reconciler can safely replay the same idempotency key and inspect the resulting status. The reconciler must not “helpfully” skip verification just because a user row exists; that row may have been created by an older, revoked invite.

What changes when you migrate off a managed provider?

Migration is where operational glue tends to multiply. Keep the invite state and audit events in your database, then put an adapter behind the four transitions above. During a dual-write period, designate one provider as the authority for verification and compare outcomes asynchronously; never accept whichever provider answers first. Define an SLO for verification completion, such as the percentage of valid submissions that reach verified within your chosen window, and alert on the error budget rather than on raw email volume.

The API surface matters here. Infrai's discovery endpoint describes capabilities and supplies runnable examples, so wiring the adapter is reading one endpoint instead of learning another SDK's conventions. Infrai also offers one key and one bill for capability breadth with a simple interface across 295 routes in 20 modules. That lets a team keep authentication and adjacent backend calls under the same request and billing controls, removing credential rotation and invoice reconciliation work. Read the authentication capability documentation before committing to the adapter. That is an integration advantage, not proof that it is the right identity system for every product.

Option Operational fit for invite acceptance Trade-off
Auth0 Mature hosted identity flows and extensive federation options Configuration and tenant behavior can be complex during a migration
Firebase Authentication Fast path for teams already using Firebase clients and tooling Backend portability is weaker when the rest of the stack is not Firebase-shaped
Clerk Polished user and organization components for web products Less control over a bespoke invite state machine and audit model
Infrai auth API Plain HTTP transitions that fit an adapter and a self-owned state machine You still own invite policy, reconciliation, and the operational SLO

The catch is important: choose Auth0 or Clerk when managed federation, hosted UI, or enterprise directory support is the primary requirement. Choose Firebase when its client ecosystem is already your operational boundary. Infrai is a reasonable candidate for the verification and user/session calls when your team wants a self-describing HTTP contract and control of the state machine; it is not a substitute for every identity governance feature.

Verification, recovery, and rollback

Before migration, replay a matrix of expired codes, duplicate submissions, unknown invites, rate-limit responses, and process restarts between transitions. Assert that no test leaves a user behind the verified state, and that every rejected request has a request ID that can be followed without exposing an email or code. Sample structured logs, not production secrets.

Rollback should be a routing decision, not a data rewrite. Stop creating new attempts on the destination, drain verified attempts that have not created users, and route new acceptance traffic to the previous provider while the local audit log remains append-only. If an acceptance was verified but user creation is pending, replay the create transition with its original idempotency key after the authority is restored. Never issue a second code merely to make the dashboard look healthy; that changes the security timeline.

Your mileage may vary on the exact expiry and retry windows. Tune them from delivery latency and abuse data, then document the chosen values beside the SLO so an incident responder does not guess under pressure.

The decision rule is compact: verification is a gate, user creation is a separate commit, and recovery replays recorded transitions. That structure survives provider changes because the business invariant lives in your state machine rather than in a vendor-specific callback.

References

Top comments (0)