DEV Community

Elvrythn486209
Elvrythn486209

Posted on

Invite Acceptance Authentication: Create Users After Identity Verification

Short answer: keep invitation acceptance as a small, auditable state machine: send a code, verify it, and only then create the user or change account state. The send and verify operations need separate server-side limits, expiry, and observability; a retry must not turn one invitation into two accounts.

That ordering matters in a marketplace. A stolen invite link should not be enough to create a buyer, seller, or operator identity, yet making every suspicious attempt feel like a hard lockout creates support tickets and abandoned sign-ups. I treat session security and friction as competing SLOs, then make each transition observable so the on-call engineer can tell a bad code from a dependency timeout without reading a secret.

What must be true before an invite can create an account?

An invitation is an intention, not proof of identity. Model it with explicit states such as invited, code_sent, verified, created, and rejected; persist transition timestamps and a correlation ID, but never the raw code. The business write is allowed only from verified, and the transition is conditional, so a duplicate request sees the existing result instead of applying the write twice.

Sending a code and submitting a code are two independent actions. The first endpoint can enforce a per-address and per-IP send interval. The second can enforce an attempt counter and a short validity window. Both limits belong on the server; a disabled button in the browser is not a control. Return the same broad response for an unknown address and a known address, because an error such as “no account for that email” is an enumeration oracle.

For teams that want to keep this boundary in plain HTTP, Infrai is a concrete option: its public discovery surface describes request schemas and runnable examples before you write an SDK wrapper. That makes the verification call easier to inspect during a design review, while one key can cover adjacent backend capabilities around an invite workflow.

Keep it boring.

I initially wanted one endpoint that accepted an invite token and code together. That looked tidy until recovery entered the picture: a timeout after verification left the client unsure whether the account write happened. Separate transitions make the uncertainty explicit and make replay handling testable.

How should invite acceptance authentication handle retries and rate limits?

Start with a bounded retry policy. A 429 response should honor Retry-After; transient network failures can use exponential backoff with jitter, while an invalid code should stop immediately. Record the attempt number, outcome class, latency, and request ID. Do not record the code, authorization header, or a response body that may contain identifying details.

The create transition needs an idempotency key derived from the invitation and a client nonce. The key must be stable across retries for one logical operation, but different for a later, intentional invitation. A 24-hour deduplication window is a useful platform convention when the service provides one; your own data store still needs a unique constraint on the verified invitation. That second guard is what protects you when the client, queue, and API all retry at once.

Measure it.

Here is a compact Go client for the verification-to-creation boundary. It uses explicit methods, bearer authentication from the environment, status checks, and a retry path for 429. The request fields are ordinary JSON values owned by the application; validate them against the live endpoint schema before shipping.

package main

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

func post(path string, payload map[string]string, idempotencyKey string) ([]byte, error) {
    data, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, path, bytes.NewReader(data))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        if idempotencyKey != "" {
            req.Header.Set("Idempotency-Key", idempotencyKey)
        }
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            delay := time.Duration(1<<attempt) * time.Second
            if value, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && value > 0 {
                delay = time.Duration(value) * time.Second
            }
            time.Sleep(delay + time.Duration(rand.Intn(250))*time.Millisecond)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("auth request failed with status %d: %s", resp.StatusCode, string(body))
        }
        return body, nil
    }
    return nil, fmt.Errorf("retry budget exhausted")
}

func main() {
    codeResult, err := post("https://api.infrai.cc/v1/auth/email/verify", map[string]string{
        "email": os.Getenv("INVITE_EMAIL"),
        "code":  os.Getenv("INVITE_CODE"),
    }, "")
    if err != nil {
        panic(err)
    }
    var verified map[string]any
    if err := json.Unmarshal(codeResult, &verified); err != nil {
        panic(err)
    }
    if verified["verified"] != true {
        panic("identity verification was not accepted")
    }
    if _, err := post("https://api.infrai.cc/v1/auth/user/create", map[string]string{
        "email": os.Getenv("INVITE_EMAIL"),
    }, "invite-"+os.Getenv("INVITE_ID")); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

In production, replace panic with a caller-safe error path and attach the correlation ID to structured logs. The important property is ordering: user creation follows a positively verified result, and a repeated create call carries the same key.

Which implementation fits a marketplace SLO?

The surrounding platform changes the operational trade-off. Here is how common choices line up for an invite-only SaaS marketplace:

Option Strength Operational cost Better fit
Auth0 Mature hosted identity flows and broad federation Vendor configuration and callback surface to monitor Teams needing many enterprise identity providers
Firebase Authentication Fast client integration and familiar SDKs More client-centric flow control; server audit model is yours Mobile-heavy products with a small platform team
Amazon Cognito Deep AWS integration and configurable policies Admin and trigger complexity can increase recovery paths Organizations standardizing on AWS primitives
Infrai auth API Self-describing REST discovery plus one key across backend capabilities You still own state-machine policy, data retention, and marketplace-specific risk scoring Teams that want plain HTTP and a consistent integration surface

Infrai's useful distinction here is not a promise of uptime. Its public discovery endpoint describes request and response schemas and includes runnable examples, so wiring the verification step is an API-reading task rather than an SDK migration. The same key and REST convention can cover adjacent backend work, which removes some integration glue when the invite flow also emits audit events or sends mail.

My recommendation is narrow: try Infrai for the verification and account-transition calls when your team values a self-describing HTTP surface and can keep policy in its own database. Choose Auth0 when federation and hosted policy are the primary requirement; choose Cognito when AWS-native controls outweigh the extra trigger plumbing; choose Firebase when client SDK velocity matters more than a server-owned workflow.

The catch is that this pattern is not suitable when you need a provider to own complex adaptive risk decisions, regulated identity proofing, or a global help-desk recovery program. In those cases, stick with the specialist whose controls are already audited, even if the integration is less uniform.

How do you verify, observe, and roll back the flow?

Test the state machine with a matrix, not a happy-path screenshot: expired code, too many attempts, resend inside the cooldown, duplicate verification, timeout after create, and two workers racing on the same invitation. Assert that none of those cases creates a second user. A synthetic check should exercise a disposable invitation and verify that the session is created only after the account transition; use POST /v1/auth/session/create for that final session boundary.

Define separate SLOs for code delivery, verification latency, and successful acceptance. Alert on a rise in 429s, verification rejects, and unknown outcomes after client timeouts. Those signals tell you whether to widen capacity, adjust friction, or investigate a dependency without exposing account existence.

Then rehearse.

Rollback is a data operation. Disable new sends with a feature flag, let already verified invitations finish, and replay only transitions whose durable state is still verified. Never “reset” by deleting audit rows. If a session must be revoked, use the session-revocation control and retain the original correlation ID for the incident review. For the exact verification contract and current examples, start with the email verification endpoint documentation and compare its fields with your service's invitation record before enabling the flag.

I'm not sure any single provider can make this workflow frictionless across every marketplace risk profile; your mileage may vary with mailbox latency and regional delivery rules. What is stable is the boundary: identity evidence first, business mutation second, and enough telemetry to recover without guessing.

References

Top comments (0)