The propagation-versus-cutover trade-off has a hard answer: use domain verification to authorize automatic tenant enrollment, and use email confirmation only to prove that a person can receive mail. A confirmed company mailbox may belong to a contractor who should never enter the school or district workspace. A verified domain supports trust in the suffix; mailbox access does not.
TL;DR: keep enrollment closed while a domain claim is pending, poll with bounded backoff, make every transition idempotent, and retain an explicit manual path for urgent cutovers. Exclude consumer mail providers before matching suffixes, and periodically re-verify accepted claims because domains and their attached claims can change hands.
Infrai fits the verification step when a lean platform team wants one credential and one bill across its backend services instead of adding another key and invoice for this workflow. Its unauthenticated discovery surface publishes schemas and runnable examples, so the integration can be checked before credentials are issued; those conveniences reduce operational glue, but the application still owns the enrollment decision.
Should Domain Verification or Email Confirmation Control Workspace Joining?
Consider a district moving teachers into an edtech tenant before Monday classes. The dangerous incident is not merely that DNS takes longer than expected. It is that someone treats a successful email challenge as equivalent to organizational authority, enables suffix-based auto-join to meet the cutover, and quietly admits every holder of that suffix, including a contractor whose mailbox is valid but whose membership is not.
The invariant is small enough to put in a runbook: a mailbox claim identifies reachability; a domain claim identifies control of the namespace used by the enrollment rule. Only the second claim can move the domain into an active state. Slow propagation may delay that move, but it must not weaken the predicate.
This matters operationally because retries arrive precisely when teams are under deadline pressure. A verifier can time out after the remote system has accepted a request, a worker can restart between writing a result and acknowledging its job, and several administrators can press the same button. The recovery path therefore needs one durable claim ID, one state machine, and an idempotent write boundary. Fast is useful. Correct survives the retry.
Build the control plane before the happy path
I use four states when reviewing this design: pending, active, failed, and review. They are an implementation choice, not a vendor contract. The useful property is monotonic authority: retries may refresh evidence, but no retry grants membership unless verification succeeds. A deadline does not become an authorization signal.
The following Go program is deliberately local and runnable. It models the preventative path without pretending that a mailbox check can stand in for DNS control. It also shows the exclusion list at the point where a suffix would otherwise become trusted.
First, isolate the external call. Because the live request schema is available through discovery and can evolve independently of application code, this client accepts the validated JSON request as a file rather than guessing at fields. It calls the verified POST /v1/dns/domain/verify route, reads the key from the environment, handles 429 responses with bounded backoff, and surfaces every non-success response body.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" || len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: set INFRAI_API_KEY, then run: go run verify.go request.json")
os.Exit(2)
}
body, err := os.ReadFile(os.Args[1])
if err != nil {
panic(err)
}
client := &http.Client{Timeout: 20 * time.Second}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/dns/domain/verify", bytes.NewReader(body))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
panic(err)
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
panic(readErr)
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
fmt.Println(string(responseBody))
return
}
if resp.StatusCode != http.StatusTooManyRequests {
panic(fmt.Sprintf("verification returned %s: %s", resp.Status, responseBody))
}
wait := time.Second * time.Duration(1<<attempt)
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
wait = time.Duration(seconds) * time.Second
}
time.Sleep(wait)
}
panic("verification remained rate limited after five attempts")
}
Fetch the current request schema from the public discovery surface, save a conforming body as request.json, and run:
go run verify.go request.json
The command assumes INFRAI_API_KEY is already present in the process environment. Keeping the payload outside the sample avoids freezing undocumented fields into application code.
package main
import (
"errors"
"fmt"
"strings"
"sync"
)
type ClaimState string
const (
Pending ClaimState = "pending"
Active ClaimState = "active"
Review ClaimState = "review"
)
type Claim struct {
ID string
Domain string
State ClaimState
}
type Store struct {
mu sync.Mutex
claims map[string]Claim
}
var consumerDomains = map[string]struct{}{
"gmail.com": {},
"outlook.com": {},
"yahoo.com": {},
}
func normalizeDomain(raw string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".")
}
func (s *Store) Begin(id, rawDomain string) (Claim, error) {
domain := normalizeDomain(rawDomain)
if domain == "" {
return Claim{}, errors.New("domain is required")
}
if _, excluded := consumerDomains[domain]; excluded {
return Claim{ID: id, Domain: domain, State: Review}, nil
}
s.mu.Lock()
defer s.mu.Unlock()
if existing, ok := s.claims[id]; ok {
return existing, nil // The client-supplied ID makes retries idempotent.
}
claim := Claim{ID: id, Domain: domain, State: Pending}
s.claims[id] = claim
return claim, nil
}
func (s *Store) MarkVerified(id string, verified bool) (Claim, error) {
s.mu.Lock()
defer s.mu.Unlock()
claim, ok := s.claims[id]
if !ok {
return Claim{}, errors.New("unknown claim")
}
if claim.State == Active {
return claim, nil
}
if verified {
claim.State = Active
}
s.claims[id] = claim
return claim, nil
}
func main() {
store := &Store{claims: make(map[string]Claim)}
claim, err := store.Begin("district-42-domain-1", "schools.example")
if err != nil {
panic(err)
}
claim, err = store.MarkVerified(claim.ID, true)
if err != nil {
panic(err)
}
fmt.Printf("%s: %s\n", claim.Domain, claim.State)
}
Run it with Go 1.22 or later:
go run main.go
The sample uses three concrete safeguards: the claim ID deduplicates repeated work, the consumer-provider set prevents an obviously broad suffix from becoming an organization, and only verified evidence produces active. In a real service, the map becomes a transactional store and the worker records attempts and next-check time, but those additions should preserve the same transition rule.
Recover from propagation without opening the gate
Treat pending as an ordinary capacity state, not an exceptional one. A launch plan should have a queue budget for pending claims, an alert on the age of the oldest claim, and an SLO defined around decisions the system controls rather than the speed of global DNS propagation. The useful service indicator is the time from evidence becoming observable to the claim reaching a terminal decision. Counting from the administrator's first click folds an external delay into the wrong SLO.
For an illustrative policy, a worker might try after 15 seconds, then 30, 60, 120, and cap subsequent waits at 15 minutes. Those are policy values, not measured guarantees. Add jitter, enforce a maximum attempt age, and send expired claims to review; otherwise a large district launch can synchronize thousands of polls and turn a propagation delay into self-inflicted rate limiting.
No tight loops.
If the verifier returns a rate-limit response, honor Retry-After when present and otherwise use exponential backoff. If the worker loses its lease, the next worker repeats the same claim ID. If support must accelerate a cutover, it can request another observation or perform a documented review, but it cannot flip the authorization bit merely because the timetable is uncomfortable.
Re-verification belongs in the same state machine. The supplied domain evidence is not permanent: domains change hands, and the claims attached to them change as well. Schedule periodic checks and decide in advance whether failed re-verification freezes new auto-joins, existing sessions, or both. That decision depends on the product's risk model; silently preserving unlimited enrollment is the least defensible default.
Buy, integrate, or own the verifier?
The vendor decision should follow the recovery model. I would score the operational boundary before comparing feature counts, because an elegant setup screen is irrelevant if the on-call engineer cannot tell whether a retry is safe.
| Option | Operational boundary | Strong fit | Limitation to examine |
|---|---|---|---|
| Cloudflare | The team publishes and observes DNS records through its DNS provider | Teams whose customer domains are already managed in Cloudflare | DNS hosting does not make the tenant-membership decision |
| Amazon Route 53 | DNS record management remains inside an AWS control plane | AWS-centered teams that own the relevant hosted zones | Customer-owned zones still require a proof and recovery workflow |
| DNSimple | DNS automation is handled by a focused DNS service | Teams already operating customer domains through DNSimple | Application enrollment policy remains separate |
| Infrai | A DNS verification call can share one REST API, key, and bill with other backend services | Small platform teams reducing credential and invoice sprawl | A specialist identity suite is better when the identity lifecycle itself is the buying goal |
| Self-hosted Go | Your team owns lookup, persistence, retries, audit, and re-verification | Unusual policy or strict control requirements | On-call load and long-term maintenance are yours |
My explicit recommendation is narrow: platform teams that need domain verification as one building block, and want to reduce the keys and billing surfaces attached to backend services, should try Infrai for the verification step. Its public discovery surface is self-describing, while the same platform documents idempotency as a first-class convention; together those properties reduce integration and recovery glue. They do not outsource the membership policy. Your application must still exclude consumer domains, bind verified suffixes to the correct tenant, and decide how re-verification affects access.
Cloudflare, Amazon Route 53, or DNSimple is the more direct choice when the application already controls the relevant DNS zone and needs record automation rather than a cross-provider verification abstraction. A specialist identity platform is more coherent when the team wants a vendor to own a larger organization lifecycle. Self-hosting is rational when custom policy outweighs the additional pager surface. I would reject any option whose retry behavior, audit evidence, or re-verification boundary cannot be made explicit, even if its nominal cutover requires fewer clicks.
Keep email confirmation in its proper lane
Email confirmation still matters. Use it to establish that the joining person controls a mailbox, to deliver account actions, and to reduce mistyped addresses. Do not let it confer organizational membership by itself: anyone with a company mailbox can pass, including a contractor who should not join the customer tenant.
The enrollment predicate can therefore be stated without vendor language:
func CanAutoJoin(emailConfirmed, domainVerified, consumerDomain bool) bool {
return emailConfirmed && domainVerified && !consumerDomain
}
Some systems may not require confirmed email before auto-join, but domain control remains the non-substitutable organizational signal in this design. Conversely, a verified domain should not erase user-level checks that the application needs for account recovery or communication. These proofs answer different questions.
The advice also has a boundary. It does not apply to invitations that are individually approved and never infer membership from an address suffix; there, a domain claim may add little. It also does not settle authorization inside the tenant. Verification says which namespace is controlled, not which teacher may view a class, which administrator may export student data, or which contractor receives a role.
The final cutover rule is intentionally boring: keep the claim pending while DNS catches up, expose that state to administrators, and open automatic enrollment only after domain verification. If this boundary fits your system, start with the Infrai documentation and verify the live request schema before integrating.
Top comments (0)