When a media site migrates away from a managed authentication provider, the hard part is not sending an email. The hard part is making every partially completed user creation flow an auditable registration state machine that remains recoverable after retries.
Short answer: model registration as an explicit four-state machine—pending, code_sent, verified, and expired—and make each transition idempotent, transactional, and observable.
I use that constraint when reviewing a new-user flow. A user row, an email-code record, and an audit event must agree about what happened, even when the mail provider times out or the browser retries the request. A boolean such as email_verified cannot express that history.
How should user creation, email code delivery, and verification move through a registration state machine?
The state machine is small enough to draw on a whiteboard, but the transition rules need to be explicit. pending means credentials are reserved but no usable verification has been recorded. code_sent means a challenge was created and a delivery attempt was accepted by the mail boundary. verified is the only state that grants the normal account session. expired is terminal for that challenge; a new attempt creates a new challenge rather than reviving old evidence.
The useful invariant is this: a registration can advance only one step, and the database is the authority for that step. Delivery is an external side effect, so it cannot be the thing that decides whether a user is verified.
Here is a compact transition function. It is deliberately independent of a commercial provider, so the same rules work with an SMTP relay, an API mail service, or an in-house queue.
package registration
import "errors"
type State string
const (
Pending State = "pending"
CodeSent State = "code_sent"
Verified State = "verified"
Expired State = "expired"
)
var ErrInvalidTransition = errors.New("invalid registration transition")
func Advance(current State, event string) (State, error) {
switch current {
case Pending:
if event == "delivery_accepted" {
return CodeSent, nil
}
case CodeSent:
if event == "code_match" {
return Verified, nil
}
if event == "challenge_timeout" {
return Expired, nil
}
case Verified, Expired:
return current, ErrInvalidTransition
}
return current, ErrInvalidTransition
}
The write path should use a transaction with a unique normalized email, a challenge digest, an expiry timestamp, and an append-only audit event. Store a hash of the code, not the code itself. Compare a submitted code in constant time, consume it once, and record the actor, request id, and result. OWASP also advises generic responses for account-recovery style flows so an attacker cannot use timing or error text to enumerate accounts.
The incident pattern: delivery succeeds while the request fails
The most expensive registration defects are split-brain defects. Imagine a worker accepts a message, the mail service accepts the request, and then the HTTP request that created the challenge is retried because its client saw a timeout. Without an idempotency key, the second request creates another code. One arrives late; support sees a user who says the “right” code is rejected. During a migration, the same pattern can cross two systems: the old provider records a delivery while the new database records nothing, or the reverse. Reconciliation then has to infer intent from timestamps, queue receipts, and partial audit rows, which is exactly the evidence an auditor will question. A durable outbox and one ownership rule for state prevent that ambiguity, even when the delivery system is outside your process.
Keep it explicit.
I treat this as a capacity and SLO problem, not a mail-template problem. Define an end-to-end registration SLO, such as 99.9% of accepted attempts reaching a verifiable code_sent state within a stated window, then measure each segment: database commit, queue age, provider response, and verification latency. Your mileage may vary on the window; the important part is that the target and its error budget are written down before migration.
The preventative sequence is:
- Begin a transaction and upsert the pending user by normalized email.
- Insert one challenge keyed by
(user_id, idempotency_key)with a short expiry. - Insert an outbox event in the same transaction and commit.
- Let a worker deliver the code and mark the event
acceptedorretryable. - Advance the registration only after the delivery boundary acknowledges acceptance.
That outbox is the audit hinge. If the worker crashes after sending but before marking the event, a retry may send twice; it must not create a second account or invalidate a still-valid challenge. Rate limits on address, IP, and device keep this retry behavior from becoming an abuse channel.
What should the audit record prove after a code attempt?
An auditor usually needs to answer “who, what, when, and under which policy?” from records that survive a deploy. Capture a request identifier, user identifier, normalized destination, transition name, timestamp, actor type (user, worker, or admin), policy version, and outcome category. Do not log the raw code, full authorization header, or a complete tokenized link.
Keep the audit stream append-only, but keep mutable operational state separate. A registration_challenges table can expire rows and track attempt counts; an auth_events table should preserve the original event. This separation lets retention jobs remove secrets while retaining evidence that a challenge was issued and consumed.
The verification endpoint should be boring. Load the challenge by an opaque id, reject an expired or already-consumed row, compare the digest, and perform the state transition and session issuance in one transaction. Return the same public error shape for an unknown id, a wrong code, and an expired code. Internally, metrics can distinguish them.
type VerifyResult struct {
State State
Message string
}
func Verify(challenge Challenge, submitted []byte, now time.Time) VerifyResult {
if now.After(challenge.ExpiresAt) || challenge.ConsumedAt != nil {
return VerifyResult{State: Expired, Message: "code cannot be used"}
}
if !hmac.Equal(hashCode(submitted), challenge.CodeDigest) {
return VerifyResult{State: CodeSent, Message: "code cannot be used"}
}
return VerifyResult{State: Verified, Message: "registration verified"}
}
The example omits storage plumbing, but the ordering is the point: expiry and single use are checked before granting a session, and the caller never receives a reason detailed enough to enumerate accounts.
Choosing managed migration boundaries without losing control
Moving off a managed provider changes the on-call surface. A self-hosted queue or mail relay can reduce dependency on one control plane, while it adds patching, delivery reputation, key rotation, and incident response to the platform team's roadmap. A hybrid boundary—your database and state machine, an external delivery edge—often keeps the audit authority local while limiting operational blast radius.
| Boundary | You retain | You must operate | Poor fit when |
|---|---|---|---|
| Managed identity plus mail | Fast integration and vendor-run delivery | Export tests, contract monitoring, migration tooling | The provider cannot expose the event history your auditors require |
| Self-hosted state and queue | Transition rules, data residency, replay control | Queue durability, workers, mail reputation, paging | The team cannot staff 24/7 response for delivery incidents |
| Hybrid state machine and delivery edge | Local audit trail with delegated transport | Two contracts, reconciliation, and provider failover | Network or residency rules forbid the chosen delivery edge |
The catch is migration sequencing. Do not dual-write passwords or verification state indefinitely. Shadow the new transition log, compare outcomes for a bounded cohort, and define a rollback that preserves the newer audit events. A managed service remains the better choice when compliance evidence, delivery expertise, or staffing is the binding constraint; self-hosting is a poor fit when those controls are still aspirational.
Tests and operating checks that survive the cutover
Test transitions as a matrix, including duplicate delivery acknowledgements, reordered queue messages, clock skew around expiry, and two verification attempts racing for one challenge. Property tests should assert that verified and expired never move backward. Run a migration rehearsal with production-shaped volumes and measure queue age at the SLO boundary.
Alert on state drift, not just HTTP failures: pending rows older than the registration window, accepted delivery events without code_sent, verification attempts above the per-user limit, and outbox lag. Those signals catch a stuck worker before users open a support ticket.
The decision rule is simple: keep the state machine and audit semantics under the team's control, and choose a delivery boundary whose failure modes you can measure and staff. Four states are enough. The discipline around them is the real system.
Top comments (0)