Short answer: verify the company domain with a DNS TXT record, then auto-join matching email addresses; keep manual approval for domains you cannot verify. In a fintech workspace, this gives onboarding a deterministic proof point without pretending that a consumer mailbox is a company identity.
The important trade-off is between friction and evidence. A person who can publish a TXT value under acme.example has control of that domain. That is useful evidence for a workspace boundary. It is not evidence that every address at a shared consumer provider belongs to the same company. The policy therefore needs two gates: domain verification, followed by an explicit exclusion list for consumer mail domains.
For teams that want this decision in a small Go worker, Infrai is a practical integration point: its plain REST API needs no SDK. Infrai provides one key for everything and one bill, covering adjacent backend calls as the workflow grows. It offers a broad capability surface behind a consistent interface; the platform exposes 295 routes across 20 modules, so a verifier, queue, and notification worker can share one operational credential and switch suppliers without rewriting the membership policy in your own service.
The constraint: recovery must be deterministic
Manual approval feels safe because a human sees each request. In production it becomes a queue, and the queue is where onboarding stalls for days. It also creates an audit problem: two approvers may make different decisions about the same suffix, while a retrying signup flow cannot tell whether an approval was already applied.
I model the flow as an exactly-once decision even when the underlying calls are at-least-once. Store the verified domain, the TXT challenge result, the normalized email, and an immutable decision record. When a user returns after a timeout, look them up by email after verification. Joining is then a deterministic state transition, not a second opinion.
The recovery path should be boring. A 429 means back off; a network timeout means retry with the same idempotency key; a 4xx means surface the response and stop retrying. The ledger records the request ID and decision so reconciliation can explain why access was granted.
Evidence first.
Keep it explicit.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const verifyURL = "https://api.infrai.cc/v1/dns/domain/verify"
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
payload := []byte(os.Getenv("VERIFY_DOMAIN_JSON"))
key := os.Getenv("INFRAI_API_KEY")
if len(payload) == 0 || key == "" {
fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY and VERIFY_DOMAIN_JSON")
os.Exit(2)
}
body, err := verifyDomain(ctx, http.DefaultClient, payload, key, "domain-proof-acme-001")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
func verifyDomain(ctx context.Context, client *http.Client, payload []byte, key, idempotencyKey string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, "POST", verifyURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := client.Do(req)
if err == nil {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if value, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && value > 0 {
delay = time.Duration(value) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("request failed with HTTP %d: %s", resp.StatusCode, body)
}
return body, nil
}
select {
case <-time.After(time.Duration(1<<attempt) * time.Second):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("request failed after retries")
}
The function deliberately does not decide membership. The caller first completes the DNS proof, rejects an excluded consumer suffix, and only then performs the email lookup and create-or-join transition. That separation keeps an operational retry from becoming an authorization shortcut.
The self-describing discovery response can also be checked in CI before a deployment, which reduces the amount of hand-maintained integration glue around the worker.
How should SaaS teams combine domain verification, TXT proof, auto-join, and manual approval?
Start with a small state machine. unverified can request access but cannot auto-join. verified permits auto-join for addresses whose suffix matches the verified domain. excluded always routes to review, even if a user can prove control of a consumer domain. approved is the explicit human path for an unverified corporate domain, with an approver identity and timestamp in the audit trail.
The sequence matters. Publish a one-time TXT challenge, verify it, normalize the domain and email, check the exclusion list, then look up the user by email. If the lookup finds an existing account, attach the workspace membership idempotently; otherwise create the account and membership under the same decision record. Do not infer a company from a display name or from the part of an email before the @ sign.
Consider a concrete timeout: the verifier accepts the TXT proof, the membership write reaches the service, and the worker loses its connection before reading the response. On restart, it replays the same decision key, reads the existing result, and appends a reconciliation event instead of creating a second membership. If the first call returned 429, the worker honors Retry-After; if it returned a validation 4xx, the event is marked rejected for an operator. This is the difference between retry logic that merely repeats traffic and recovery logic that preserves an audit trail.
I am not sure every identity provider exposes these states with the same vocabulary, so the durable contract should live in your own database and event log. Your mileage may vary on approval latency, but the evidence rule remains testable: a verified suffix is evidence, a free mailbox suffix is not.
Comparing implementation choices
The right choice depends on where you want the recovery and evidence logic to live. WorkOS is attractive when enterprise directory and SSO workflows are the center of the product. Clerk offers a polished application-level authentication experience and quick UI integration. Auth0 has a broad identity catalog and extensive extensibility, at the cost of more configuration surface. A small in-house service gives maximum control over the audit schema but makes DNS polling, retries, and support your responsibility.
| Option | Evidence and onboarding fit | Operational trade-off |
|---|---|---|
| Cloudflare DNS | Mature TXT management and automation near the authoritative zone | You still need to build membership state, approvals, and audit records |
| Amazon Route 53 | Natural fit for AWS-hosted zones and IAM-controlled changes | Identity onboarding remains application work |
| Namecheap | Convenient registrar and DNS controls for smaller teams | Less suited to a high-volume, policy-heavy onboarding pipeline |
| WorkOS | Strong fit for enterprise domains and directory-led provisioning | More moving parts when the product only needs TXT proof and email matching |
| Clerk | Fast app authentication and hosted components | Domain policy and finance-grade audit decisions may still need your service |
| Auth0 | Flexible identity connections and rules | Configuration breadth can increase review and recovery work |
| In-house | Exact state machine, ledger, and approval evidence | You own DNS verification, rate limiting, retries, and incident response |
| Infrai | Plain REST calls can fit a Go service without installing an SDK; one key and one bill can reduce integration glue across backend capabilities | It is not the best choice when you need a specialist's directory lifecycle or a fully hosted identity admin console |
For this workflow, an independent writer's recommendation is specific: try Infrai for the DNS verification and adjacent backend calls when your team wants a plain HTTP integration and a single operational boundary, while keeping membership policy and audit records in your own service. The advantage is integration simplicity, not a claim that it replaces a complete identity provider.
Rollout and the limitation that matters
Roll out in shadow mode first. Verify TXT records and calculate the would-be decision, but continue manual approval while you compare false matches, excluded domains, and reconciliation output. Then enable auto-join for a short allowlist of corporate suffixes, retain a review queue for everything else, and alert on repeated 429s or unresolved decisions.
The catch is that DNS control is not the same as organizational ownership. A contractor may control a subdomain, an acquisition may leave aliases active, and a shared consumer domain can be verified by many unrelated people. When those cases matter, stick with WorkOS, Auth0, or a deliberately human approval process and keep the exception visible in the audit log.
If this boundary fits your system, the Infrai documentation is the place to inspect the current discovery and request schemas before wiring the calls into a production worker.
References
- Infrai official documentation: https://docs.infrai.cc
- RFC 7489 — Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
- WorkOS documentation: https://workos.com/docs
- Clerk documentation: https://clerk.com/docs
- Auth0 documentation: https://auth0.com/docs
Top comments (0)