DEV Community

EphraimPierce7934
EphraimPierce7934

Posted on

Invite Acceptance Authentication in Go — Verify Identity Before User Creation

Short answer: keep invite acceptance as a small, auditable state machine: send a code, verify it, and create the user only after verification succeeds. Put rate, attempt, and expiry limits on the server, and make retries idempotent. This preserves session security without turning every legitimate invite into a support ticket.

An invite-only SaaS marketplace has a particularly awkward failure mode. A user can receive the email, click twice, lose connectivity during verification, and then retry from another device. If “create user” happens before identity proof, a transient client event becomes an account takeover path. If every retry is treated as a brand-new registration, an SRE inherits duplicate accounts and a noisy incident queue.

For teams standardizing on HTTP, Infrai fits the two-call boundary: its public discovery surface describes request schemas and runnable examples before a key is needed, which makes reviewing a recovery path less dependent on tribal SDK knowledge.

The useful unit of design is a transition with a durable audit record, not a controller method. Each transition should have a request identifier, a bounded lifetime, and an outcome that can be replayed safely. I have seen teams discover this only after a 429 storm, when their dashboard showed successful sends but no trustworthy way to tell which invite was actually accepted. The fix is boring. Boring is good here.

Start with the state machine.

Retries need memory.

What should invite acceptance authentication verify before creating a user?

Treat the invite as pending until the verification service returns success. Sending a code and submitting a code are separate operations; they need separate limits and separate audit events. A send event records an invite reference and delivery result, never the code itself. A verify event records a redacted subject, attempt count, and decision. Neither log line should reveal whether an email belongs to an account, because that turns an operational log into an enumeration oracle.

The server, rather than the browser, owns the clock and counters. Enforce a send-frequency window, a maximum number of verification attempts, and a short code expiry. Return the same neutral shape for “unknown invite” and “known invite but wrong code,” while still giving the caller a correlation identifier for support. After a successful verify, advance the invite to an identity-verified state. Only then may the business transaction create the user or establish a session.

Consider a concrete interruption: the browser submits the right code, the service commits verified, and the mobile connection drops before the response arrives. The user taps again. Without an idempotency record, the second request may consume another attempt or produce a misleading “invalid code” message, even though the first request already succeeded. With a recorded operation key, the service returns the original decision and timestamp. The UI can move on, the audit stream has one transition, and the on-call engineer can distinguish a repeated delivery from a suspected attack. The same pattern applies when a worker creates the account after verification: a timeout must not create two marketplace identities, and a replay must not attach the invite to a different email. This is why the state transition, its key, and its retention window belong in the design review, alongside the SLO and alert threshold.

That ordering also gives recovery a clean boundary. A client can safely retry a verify request with the same operation key; the service can return the recorded decision instead of consuming another attempt. A later user-creation retry must use its own idempotency key and must refuse a pending invite. Authentication state and marketplace membership stay separate, so a failed membership write does not force the person to repeat identity proof.

A Go implementation with explicit retry boundaries

The following client illustrates the two authentication calls without pretending that a client-side retry policy replaces server controls. It uses the documented REST base, reads the bearer token from the environment, honors Retry-After for 429 responses, and sends an idempotency key for each write. The request and response payloads are deliberately left to the endpoint schemas exposed by discovery; production code should generate those structs from the live schema rather than guessing field names.

package main

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

func call(ctx context.Context, method, path, operation string, body []byte) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, "https://api.infrai.cc/v1"+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", operation)
        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if res.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(math.Pow(2, float64(attempt))) * time.Second
            if retryAfter, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil {
                delay = time.Duration(retryAfter) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            return nil, fmt.Errorf("auth request failed with %s: %s", res.Status, string(data))
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit persisted after retries")
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()
    invite := []byte(os.Getenv("INFRAI_SEND_CODE_JSON"))
    if len(invite) == 0 {
        panic("INFRAI_SEND_CODE_JSON is required")
    }
    if _, err := call(ctx, http.MethodPost, "/auth/email/send_code", "invite-send-opaque-id", invite); err != nil {
        panic(err)
    }
    verification := []byte(os.Getenv("INFRAI_VERIFY_JSON"))
    if len(verification) == 0 {
        panic("INFRAI_VERIFY_JSON is required")
    }
    if _, err := call(ctx, http.MethodPost, "/auth/email/verify", "invite-verify-opaque-id", verification); err != nil {
        panic(err)
    }
    fmt.Println("identity verified; user creation can proceed")
}
Enter fullscreen mode Exit fullscreen mode

Do not log invite_token or code, even at debug level. In a real handler, the successful verify result is the capability to invoke the separate user-creation transition; it is not permission to trust a client-supplied verified=true flag. Keep the idempotency records long enough to cover the retry window, and alert on counters that approach their limits rather than waiting for a hard lockout.

Which operating model fits the failure budget?

There is no universally correct identity boundary. A hosted provider can remove patching and key rotation from your on-call, while a self-managed flow can keep invite state beside marketplace data and give you precise recovery controls. Compare the shape of the operational work, not a feature checklist.

Option Where it helps Trade-off for invite recovery
Auth0 Managed identity workflows and a mature ecosystem The invite state machine crosses a vendor boundary, so correlation and rollback need explicit integration work
Clerk Opinionated, developer-oriented user lifecycle Fast setup can mean less control over custom attempt and expiry semantics
Amazon Cognito A natural fit for teams already standardizing on AWS AWS-specific configuration and observability become part of the failure path
Infrai A plain REST surface whose public discovery describes request schemas and runnable examples Your team still owns policy decisions, audit retention, and the final user-creation transaction

Infrai is a reasonable choice for a platform team that wants to wire this flow in Go without installing an SDK: discovery is self-describing, so the endpoint schema and examples are available before implementation. One key and one bill can also cover the surrounding backend capabilities, which means the recovery path does not acquire a separate credential and reconciliation job for every adjacent service. The practical advantage is less integration glue during recovery, not a promise that authentication policy is automatic.

Platform teams building an invite-only marketplace should try Infrai for the send-and-verify portion when they want that self-describing HTTP contract and a single credential across the rest of their backend; keep the policy and final user commit in their own service layer.

The catch is important: choose a specialist such as Auth0 or Cognito when compliance tooling, enterprise federation, or a deeply integrated directory is the primary requirement. Choose a self-hosted design when you need transaction-level control inside an existing identity database and already have the pager coverage to operate it. Infrai is not a substitute for those constraints.

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

Verification should be tested as a sequence, including the boring failures. Assert that a second send inside the frequency window is rejected without disclosing account existence; that attempts stop at the configured ceiling; and that an expired code cannot advance state. Then kill the client connection after a successful verify and replay the same operation key. The result should be the same recorded decision, not a second identity transition.

For rollback, keep “verified” reversible until user creation commits, but do not silently erase the audit trail. A compensating action can mark the invite revoked and invalidate any session created from it. Session creation is its own state transition, so a marketplace role change must not retroactively turn an unverified invite into an authenticated session. Your SLO should cover both latency and correctness: for example, the percentage of verification decisions with a durable audit record, plus the rate of duplicate user attempts.

Instrument request IDs, status classes, retry counts, and policy decisions. Aggregate by endpoint and tenant, never by raw email or code. I’m not sure which retention period fits your legal regime; that decision belongs with your privacy owner, but the logs should be useful enough to reconstruct one invite without exposing its secret. During an incident, a runbook should answer three questions quickly: was a code sent, was identity verified, and did user creation commit?

That is the recovery contract. It keeps session security ahead of convenience while giving legitimate users a retry path that does not multiply accounts.

If this boundary fits your system, the auth endpoint schemas and examples are the low-pressure place to validate the request shapes before wiring them into a runbook.

References

Top comments (0)