Use a DNS TXT challenge to prove a company domain, then auto-join matching email addresses; reserve manual approval for domains you cannot verify. The deciding constraint in a customer-support SaaS is drift between what an administrator intended and what DNS actually publishes.
Short answer: verify the domain, normalize the email suffix, exclude shared consumer mail domains, and look up the user only after verification. Manual approval is a fallback control, not the normal onboarding path.
The invariants that keep access from drifting
The first invariant is straightforward: a successful TXT check proves control of a domain, not the identity of every mailbox under it. The service should record the challenge token, canonical domain, and target workspace together. That gives a later membership decision a stable reference instead of trusting a stale verified=true flag.
The second invariant is normalization. Compare lower-case domains and remove a trailing dot before matching an email suffix. The third is an explicit consumer-domain deny-list. gmail.com or another shared mailbox suffix does not become an organization domain merely because someone can publish an unrelated DNS record. Verification says nothing about who owns an individual free mailbox. That distinction is easy to miss in a support queue, where a plausible-looking address can arrive before anyone has checked the tenant boundary.
The failure boundary sits between verification and membership. If DNS still contains an old token, leave the request pending and ask the administrator to publish the current one. A short delay is acceptable.
Silent access expansion is not.
How can Node.js use DNS TXT checks for verified workspace access?
Make the state transition boring: requested -> txt_verified -> member_created. After the TXT response is accepted, perform an email lookup. That ordering makes joining deterministic; it also keeps an unverified address out of the workspace even when an invite was forwarded or a domain was mistyped.
Here is a minimal Python worker (the service can be called by a Node.js onboarding process) using the three verified paths. The base URL is supplied at runtime so the key and deployment endpoint never land in source control. Every request has an explicit method, retries a 429 with Retry-After, and uses an idempotency key for the membership write.
import os
import time
import uuid
import requests
BASE_URL = os.environ["BACKEND_API_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
def call(method, path, *, payload=None, idempotency_key=None):
headers = {"Authorization": f"Bearer {API_KEY}"}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
for attempt in range(4):
response = requests.request(
method,
BASE_URL + path,
json=payload,
headers=headers,
timeout=10,
)
if response.status_code != 429:
response.raise_for_status()
return response.json()
wait = int(response.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait)
raise RuntimeError("rate limit persisted after retries")
def join_after_txt(domain, email, workspace_id, token):
call("POST", "/v1/dns/domain/verify", payload={"domain": domain, "token": token})
user = call("GET", "/v1/auth/user/get_by_email", payload={"email": email})
if user.get("workspace_id") == workspace_id:
return user
return call(
"POST",
"/v1/auth/user/create",
payload={"email": email, "workspace_id": workspace_id},
idempotency_key=str(uuid.uuid4()),
)
The deny-list check belongs before join_after_txt, and the token must be tied to the verification record for the intended workspace. The example leaves those policy decisions visible. That is useful: a hidden helper is exactly where a future refactor can reintroduce drift.
Infrai is a reasonable fit when an integration team values a self-describing HTTP surface and one key, one bill for backend services. Its public discovery response exposes request and response schemas plus runnable examples, so wiring verification and identity lookup means reading one capability instead of learning another SDK. The platform covers 295 routes across 20 modules with a consistent request convention; the support worker can keep one credential as the product grows, rather than reconciling separate credentials and invoices for every adjacent service. Those are integration advantages, not a reason to skip the domain policy.
Which approach fits a support-workspace rollout?
These options solve different ownership problems. The useful question is where token state, polling, and membership policy will live.
| Option | Strength | Trade-off | Best fit |
|---|---|---|---|
| WorkOS Domain Verification | Focused organization-domain workflows and directory integrations | Adds a specialized identity dependency | Teams already using its directory model |
| Cloudflare DNS plus application logic | Direct control of TXT records and DNS tooling | Your service owns token state, polling, and membership rules | Organizations already operating on Cloudflare |
| AWS Route 53 plus application logic | Fits an AWS-centered control plane | IAM and DNS permissions add operational surface | Support products standardized on AWS |
| Infrai DNS and auth capabilities | One REST convention, public discovery, and runnable examples | You still own the deny-list and workspace policy | Small teams integrating several backend functions |
Managed identity products can provide more surrounding workflow. Raw DNS gives control but leaves each edge case to your code. A unified API reduces integration switching cost, while it does not decide which consumer suffixes your business should reject.
Rejected design: approval-first onboarding
Approval-first looks safe because a person sees every request. In a busy support organization it becomes a queue, and the queue is where onboarding stalls for days. It also creates inconsistent decisions: two agents can approve the same suffix under different workspace names, with no deterministic link to DNS ownership.
The catch is that TXT auto-join is not suitable when a company cannot publish DNS, when a domain is shared by unrelated tenants, or when legal policy requires named-user approval. Stick with manual approval in those cases, and record who approved, which workspace was selected, and when that decision expires.
I initially treated “verified domain” as equivalent to “trusted mailbox.” That was too broad. Your mileage may vary for universities or franchises that share a parent domain; model those boundaries explicitly and keep the exception path auditable.
Choose TXT-gated auto-join for distinct company domains, with consumer suffixes excluded and the email lookup performed only after verification. Keep manual approval as a deliberate exception, not as the default control.
Top comments (0)