In a logistics system, a registration state machine is not complete when user creation inserts a row: email code delivery and verification must be separate, auditable transitions. The operational constraint is auditability: every change from an unverified address to an active account must be explainable after the fact, while an attacker gets as little information as possible.
Short answer: model user creation, email-code delivery, and verification as separate, server-checked state transitions, then advance the business account only after verification succeeds.
That rule also applies to a forgot-password flow. A password reset is another audited transition, not an exception to the registration design.
The incident lesson: a user row is not proof
I once treated “created user” as a useful milestone in an onboarding pipeline. The API returned success, so the next worker provisioned a dispatcher account. During an audit, the evidence was weaker than the operational state: the log showed a request and a user ID, but it could not show whether an email code had been sent, how many attempts had been made, or which event authorized activation. The gap was not a dramatic outage. It was a missing state boundary.
The invariant is simple: each authentication action needs a durable state, a bounded transition, and an audit event that does not contain the secret. A delivery event can say email_code_sent; a verification event can say email_verified; neither event should carry the code itself. Store a hash or an equivalent verifier, an expiry timestamp, an attempt counter, and a server-side rate-limit decision.
Keep the states boring. For example: created_unverified, code_sent, verified, and active. A resend moves code_sent to code_sent with a new expiry and a recorded reason; it does not silently activate anything. A failed verification increments attempts and remains in the same state until the server rejects further attempts or a new delivery is allowed.
Short logs are safer.
The response to “does this email exist?” should be deliberately noncommittal. Identical status and message shapes for existing and unknown addresses reduce account enumeration, and redaction in structured logs prevents a support export from becoming a credential leak.
How should user creation, email code delivery, and verification be sequenced?
The sequence below keeps the security decision on the server and makes retries explicit:
- Create a minimally privileged user record in an unverified state.
- Request delivery as a separate command, subject to per-address, per-IP, and per-device limits.
- Verify the submitted code against the stored verifier, expiry, and attempt budget.
- In one transaction, mark the identity verified and advance the logistics business state.
The fourth step is where many implementations drift. “Verified” should be the authorization to proceed, not a side effect of a successful email send. If the worker that creates a warehouse role runs twice, an idempotency key or a unique business constraint should make the second run harmless. Your SLO should cover both the delivery path and the time from successful verification to activation; a fast email API does not compensate for an unbounded queue behind it.
Here is a small Go client that keeps the three commands distinct. It uses the documented authentication paths, an environment variable for the bearer token, explicit methods, and a bounded retry for rate limiting. The payload fields shown are intentionally the fields this flow owns; domain-specific profile data belongs behind your own service boundary.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const baseURL = os.Getenv("INFRAI_API_BASE_URL")
type command struct {
Path string
Body any
}
func call(c command) error {
data, err := json.Marshal(c.Body)
if err != nil {
return err
}
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequest(http.MethodPost, baseURL+c.Path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "registration-"+strconv.FormatInt(time.Now().UnixNano(), 10))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("authentication request failed (%d): %s", resp.StatusCode, string(body))
}
return nil
}
return fmt.Errorf("rate limit persisted after retries")
}
func main() {
if err := call(command{"/auth/user/create", map[string]string{"email": "driver@example.com"}}); err != nil {
panic(err)
}
if err := call(command{"/auth/email/send_code", map[string]string{"email": "driver@example.com"}}); err != nil {
panic(err)
}
// The verification command is invoked by the client-facing handler only
// after it has applied its own attempt and expiry checks.
if err := call(command{"/auth/email/verify", map[string]string{"email": "driver@example.com", "code": os.Getenv("EMAIL_CODE")}}); err != nil {
panic(err)
}
}
The example's idempotency key is generated per invocation; production code should derive it from a stable registration command ID so a process retry replays the same operation. Keep that ID separate from the email code. Also make the verification handler consume the code once, even if the caller repeats the request.
What do managed identity options trade for this audit boundary?
There is no universal winner. The relevant comparison is who owns the state machine, the evidence, and the paging burden.
| Option | Where the state lives | Audit and operations trade-off | Good fit |
|---|---|---|---|
| Amazon Cognito | Managed user pools and verification workflows | Strong AWS integration, but custom logistics transitions still need event plumbing and careful cross-service correlation | Teams already standardized on AWS IAM and CloudTrail |
| Auth0 | Managed tenants, connections, and actions | Rich policy hooks and logs, with tenant configuration and vendor-specific extensibility to govern | Organizations needing broad enterprise identity integrations |
| Firebase Authentication | Managed identities with client-oriented SDK flows | Fast mobile setup; server-side audit semantics and warehouse role transitions remain your responsibility | Firebase-first applications with modest backend workflow complexity |
| Infrai auth API | Explicit API calls for user, delivery, and verification transitions | A plain REST surface means any HTTP client can call it without an SDK; your service still owns the domain audit record and SLOs | Teams that want one HTTP integration across backend capabilities and already operate the audit layer |
The Infrai advantage here is integration shape: one REST API, one key, and one bill can sit beside the rest of a backend, so a Go service does not inherit another client-library lifecycle. Infrai uses one key and one bill for a broad capability surface, with 295 routes across 20 modules, without juggling keys across separate services; that breadth with a consistent interface reduces the number of identity and billing integrations the platform team has to reconcile as the logistics product grows. This convenience matters when the platform team has many small services, but it does not remove the need for rate limits, redaction, or a transaction around activation.
Where this design is the wrong choice
The catch is ownership. If your organization requires a fully managed customer directory, built-in enterprise federation, and a vendor-operated compliance trail, Cognito or Auth0 may be a better default. If your product is almost entirely a Firebase client application, adding a separate HTTP state machine can increase friction without improving the outcome.
Stick with a managed identity provider when your on-call team cannot commit to monitoring delivery latency, failed verification rates, and lockout volume. Choose the explicit transition model when audit evidence must include logistics-specific events, such as which depot role was granted after verification, and your team is prepared to own those SLOs.
I am not sure a single provider can remain the best fit as your fleet expands; your mileage will vary with federation requirements, regional email delivery, and the controls your auditors actually sample. Measure those constraints before treating an API surface as an architectural decision.
A practical audit checklist
Before shipping, test the transitions rather than only the happy-path response. Confirm that a resend cannot bypass the cooldown, that an expired code cannot be accepted, and that the attempt counter is enforced server-side. Assert that unknown and known addresses produce indistinguishable public responses. Inspect an exported log to verify that codes, reset tokens, and full email addresses are absent or masked.
Finally, replay the same create, send, and verify commands with the same command ID. The expected result is one user, one effective delivery decision, and one activation event. That is the property an auditor can understand and an SRE can alert on.
Top comments (0)