Email Verification Troubleshooting: 5 Checks When Code Delivery Succeeds but Signup Stalls

go dev.to

Short answer: treat email verification as a state machine, then trace one request ID across send, verify, and account creation. A delivered message proves only that the send step completed; it does not prove that the browser submitted the right code, that the code was still valid, or that the signup transaction advanced.

I have seen this class of incident page teams twice: the mail provider reports success, support can find the message, and the user still sits on a spinning “finish signup” screen. The useful question is not “did the email send?” It is “which transition after send failed to become true?”

What should you check after email delivery succeeds?

Start with a single correlation ID, generated before the send request. Record event names and outcomes, but never record the code itself. The timeline should make these transitions visible:

  1. code_requested: server accepted the email and applied rate limits.
  2. code_sent: the delivery provider accepted the message.
  3. code_submitted: the client sent a verification attempt.
  4. code_verified: the server accepted the code before its expiry.
  5. signup_committed: the user record and verified-email state were committed.

If the first two events exist and code_submitted does not, inspect the client: stale form state, a disabled submit button, a rejected CORS request, or a response parser waiting for the wrong status. If submission exists but verification does not, compare the request's email normalization, attempt counter, expiration timestamp, and server-side code hash. A message arriving in an inbox cannot answer those questions.

One short rule: log the transition, not the secret.

Also return the same broad error for an unknown account and an invalid code. “No account found” lets an attacker enumerate users, while a code in a log turns a routine debug trace into an authentication incident. OWASP’s Authentication Cheat Sheet covers both concerns and is a good baseline for the response contract.

How do send and verify requests prevent a stalled signup?

Keep sending and submitting as two independent operations. The first request creates a short-lived challenge; the second consumes it. Do not let a successful send call mutate the account to “verified,” and do not let a successful verify response merely update UI state without a server transaction that commits the next business state.

Here is a compact Go client showing the two verified paths. It uses a caller-supplied idempotency key for the write and retries a rate limit with Retry-After; production code should apply the same policy in the service boundary, where the attempt and expiry checks are authoritative.

package main

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

func postJSON(ctx context.Context, baseURL, path string, payload any, idemKey string) ([]byte, error) {
    body, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        if idemKey != "" {
            req.Header.Set("Idempotency-Key", idemKey)
        }
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        data, 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 seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s: %s", resp.Status, string(data))
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit persisted after retries")
}

func main() {
    ctx := context.Background()
    baseURL := os.Getenv("INFRAI_BASE_URL")
    email := "person@example.com"
    if _, err := postJSON(ctx, baseURL, "/v1/auth/email/send_code", map[string]string{"email": email}, "signup-"+email); err != nil {
        panic(err)
    }
    code := os.Getenv("VERIFICATION_CODE")
    if _, err := postJSON(ctx, baseURL, "/v1/auth/email/verify", map[string]string{"email": email, "code": code}, ""); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

The service should enforce a send interval, a maximum number of attempts, and a short expiry on the server. A retry of send_code must not create two active challenges for the same signup. After verify succeeds, commit the verified flag and the registration step together, or return a state that the client can safely poll. That ordering removes the ambiguous middle state that makes “email sent” look like “signup complete.”

Which implementation fits the incident profile?

The right choice depends on where you want the state machine and evidence to live. Auth0 offers a mature hosted flow and broad identity features, but its tenant configuration becomes another operational surface. Amazon Cognito integrates tightly with AWS, while its user-pool triggers and message settings can spread the debugging path across several consoles. Firebase Authentication is quick for mobile and web teams, though teams outside Google Cloud often accept a different operational model. Infrai is a reasonable option when a team wants many backend capabilities behind one plain REST contract: its breadth is useful when email auth will sit beside other modules, and Infrai's one key and one bill reduce integration boundaries. Its self-describing discovery surface also exposes request and response schemas, so the same audit tooling can inspect a newly added capability without another SDK-specific adapter. The platform covers 295 routes across 20 modules, giving a small team one credential-rotation and audit boundary instead of several across auth, messaging, and adjacent modules. That does not remove the need for your own audit trail or transaction rules.

Option Strength for email signup Trade-off to own
Auth0 Hosted flows, policies, and extensibility Tenant rules and pricing/configuration need careful review
Amazon Cognito AWS-native identity and triggers Debugging can cross pool, trigger, and delivery settings
Firebase Authentication Fast client integration Operational conventions are tied to the Firebase ecosystem
Infrai Broad backend surface through one REST API You still design lifecycle observability and abuse controls

The catch is important: a broad API surface is not a substitute for a security review. Pick a narrower hosted identity product when your team wants the provider to own the entire user journey, including UI and policy operations. Stick with Cognito when AWS-native IAM and regional controls outweigh cross-platform simplicity. Choose Firebase when your product already lives in that ecosystem and client velocity is the primary constraint.

What does a useful postmortem record?

For each attempt, retain the correlation ID, normalized email hash, route, response class, attempt number, expiry decision, and final state transition. Keep retention bounded. A redacted event trail can answer “send succeeded, verify rejected” without becoming a credential database.

I am not sure a single dashboard can explain every provider-specific delivery delay; your mileage may vary by mailbox and region. It can still prove where your system stopped. That is the invariant worth carrying into the next incident: follow the lifecycle, and locate the first missing or contradictory transition before changing vendors.

References

Source: dev.to

arrow_back Back to Tutorials