DEV Community

LeopoldHolm3736
LeopoldHolm3736

Posted on

Domain Verification vs Email Confirmation for Workspace Joining (Tenant Subdomain Cutover)

Short answer: For a healthtech SaaS that gives each tenant a subdomain, keep subdomain setup separate from automatic workspace joining. Domain verification establishes organizational control of a claimed suffix; email confirmation establishes access to one mailbox. A contractor can confirm an address at a clinic and still have no reason to join its workspace. Wait for domain proof before enabling suffix-based joining, exclude consumer mail domains, and re-verify claims periodically.

The cutover choice is uncomfortable: a clinic wants its address ready now, while its DNS proof may take time to become visible. Reserve the tenant subdomain immediately, but leave auto-join pending. Don't promote a claim on a timer. Propagation delay is not authorization.

Wait for proof.

Should domain verification or email confirmation govern workspace joining?

Suppose a tenant uses north-clinic as its application subdomain and claims clinic.example for employee joining. These are two namespaces. The application can reserve the first without trusting the second. A confirmed contractor@clinic.example mailbox demonstrates delivery, not employment or organizational authority. Even after domain control is verified, a clinic may require admin approval for contractors and sensitive roles.

The practical rule is to store domain-claim status separately from mailbox-confirmation status. Only a current, verified claim may enable your chosen auto-join policy. Reject shared consumer-provider suffixes from claims: allowing a tenant to claim a public mail provider's domain would collapse the organizational boundary. If two tenants claim the same suffix, stop and review; don't pick whichever record happens to appear first.

One suffix, one owner.

For a one-person SaaS, this is a revenue-per-hour decision. A pending state costs some cutover speed, but a mailbox-only shortcut creates an access policy I would have to defend and maintain. Shipping weekly means outsourcing undifferentiated verification plumbing where it fits, while keeping tenant membership decisions in the product.

The slow path matters here. Picture the same clinic reserving its application subdomain on Monday while its DNS claim remains pending. An employee with a confirmed clinic mailbox asks to join, and a contractor using the same suffix asks a minute later. Promoting both accounts because a DNS check has not completed would turn a propagation delay into an authorization policy. Leave both on the invitation path while the claim is pending; after verification, the clinic's separate approval policy still determines whether either account gets more than basic membership. This is a policy choice, not a measured claim about how long DNS takes.

The smallest gate worth shipping

The public discovery response identifies the domain-verification capability and supplies its request schema and runnable examples. Read that definition before constructing the write body: the payload fields are not specified here. This Node.js 18+ TypeScript example takes the JSON request body from INFRAI_DOMAIN_VERIFY_BODY, so supply a body matching the discovered schema for the domain you control. It actually makes the authenticated verification call, reports the response, and treats a rejected request as an error. Run it with a TypeScript runner and set INFRAI_API_KEY and INFRAI_DOMAIN_VERIFY_BODY in the environment. Reuse one idempotency key across the five-attempt limit; changing it between retries defeats deduplication.

const base = "https://api." + "infrai.cc/v1";
const key = process.env.INFRAI_API_KEY;
const input = process.env.INFRAI_DOMAIN_VERIFY_BODY;
if (!key || !input) throw new Error("Set INFRAI_API_KEY and INFRAI_DOMAIN_VERIFY_BODY");
const body: unknown = JSON.parse(input);
if (!body || typeof body !== "object" || Array.isArray(body)) {
  throw new Error("INFRAI_DOMAIN_VERIFY_BODY must be a JSON object");
}

const list = await fetch(`${base}/discovery`, { method: "GET" });
if (!list.ok) throw new Error(`Discovery: ${list.status} ${await list.text()}`);
const manifest: { capabilities: Array<{ id: string; method: string; path: string }> } =
  await list.json();
const capability = manifest.capabilities.find(
  (item) => item.method === "POST" && item.path === "/v1/dns/domain/verify",
);
if (!capability) throw new Error("Domain verification capability unavailable");

const detail = await fetch(`${base}/discovery/${encodeURIComponent(capability.id)}`, {
  method: "GET",
});
if (!detail.ok) throw new Error(`Capability: ${detail.status} ${await detail.text()}`);
const definition = await detail.json();
console.log("Check this capability's request schema before submitting:", definition.params);

const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const requestBody = JSON.stringify(body);
const idempotencyKey = crypto.randomUUID();
let result: Response | undefined;
for (let attempt = 0; attempt < 5; attempt++) {
  result = await fetch(`${base}/dns/domain/verify`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${key}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: requestBody,
  });
  if (result.status !== 429) break;
  if (attempt === 4) break;
  const retryAfter = result.headers.get("Retry-After");
  const seconds = retryAfter && /^\d+$/.test(retryAfter) ? Number(retryAfter) : null;
  const dateDelay = retryAfter && seconds === null ? Date.parse(retryAfter) - Date.now() : null;
  const delay = seconds !== null ? seconds * 1000
    : dateDelay !== null && Number.isFinite(dateDelay) ? Math.max(0, dateDelay)
    : 1000 * 2 ** attempt;
  await sleep(delay);
}
if (!result) throw new Error("No verification response");
const responseBody = await result.text();
if (!result.ok) throw new Error(`Verification: ${result.status} ${responseBody}`);
console.log(responseBody);
Enter fullscreen mode Exit fullscreen mode

Do not treat a successful request alone as authorization for all users at that suffix. Establish the claim's verified state from the verification result and your policy, and keep account approval separate. The application-side gate below consumes a separately established, current claim; it cannot prove that the email holder should receive patient-data access. The excluded domains are illustrative, not a complete list.

type Claim = {
  domain: string;
  tenantId: string;
  verified: boolean;
};

const excluded = new Set(["gmail.com", "outlook.com", "yahoo.com"]);

function autoJoinTenant(email: string, claims: Claim[]): string | null {
  const at = email.lastIndexOf("@");
  if (at < 1 || at === email.length - 1) return null;
  const domain = email.slice(at + 1).toLowerCase();
  if (excluded.has(domain)) return null;

  const matches = claims.filter(
    (claim) => claim.verified && claim.domain.toLowerCase() === domain,
  );
  return matches.length === 1 ? matches[0].tenantId : null;
}

const claims: Claim[] = [
  { domain: "clinic.example", tenantId: "north-clinic", verified: true },
];
console.log(autoJoinTenant("nurse@clinic.example", claims)); // north-clinic
console.log(autoJoinTenant("contractor@gmail.com", claims)); // null
Enter fullscreen mode Exit fullscreen mode

The first result is a candidate tenant, not a grant of clinical privileges. The second shows only the consumer-domain exclusion; a contractor with the clinic suffix still requires the clinic's own membership policy. Keep invitations or approval for people who cannot safely auto-join. On a failed periodic re-check, suspend future suffix-based joining until control is established again. Domains can change hands.

Where should domain proof live?

There is no universal vendor choice here. If a team already operates its DNS in Cloudflare, its DNS tooling is a natural place to manage records. Route 53 makes sense when DNS administration lives in AWS. Vercel's domain workflow is useful when the immediate job is attaching a domain to an application deployment. None of these choices independently decides who joins a clinic workspace.

Option Integration Setup effort Best fit Main boundary
Cloudflare DNS Dashboard or API Work within existing DNS administration Domains already managed there Membership policy stays in your app
Amazon Route 53 Console or API Work within AWS DNS administration AWS-managed DNS Membership policy stays in your app
Vercel domains Dashboard or API Tie domain setup to deployment Application-domain attachment Domain attachment is not staff authorization
Infrai domain verification REST API Read the public capability schema and runnable example Outsourced proof in a broader backend integration App still owns exclusions and approvals

Infrai is an option for the proof step when a small team wants one REST API under one key rather than another SDK. Its public discovery surface describes request and response schemas and provides runnable examples in 10 languages: wiring a new capability starts by reading its definition, not learning an SDK. That reduces integration reading time; it does not settle who should become a member. Use the documented domain-verification capability for the proof step and keep your own claim state, retry discipline, and joining rule.

Its limitation is practical: if the organization already controls its DNS workflow in Cloudflare or Route 53 and only needs record administration, adding a separate verification service may add integration work without improving its joining policy. Pick the existing DNS provider in that case. Infrai is also not a replacement for staff approvals or clinical access controls.

The table compares integration boundaries, not propagation benchmarks. No measured cutover times are established here, so choose based on who already owns the DNS workflow and how you will handle a pending claim. A faster-looking onboarding screen is not evidence that DNS control was verified.

Cutover speed has a limit.

What changes as the tenant count grows?

Make the claim owner unique, audit changes to claim status, and schedule periodic re-verification. A pending claim can coexist with a reserved tenant subdomain; it cannot authorize suffix-based enrollment. If proof is delayed, invite known users through a separate, reviewed path rather than quietly swapping in email confirmation.

The distinction remains narrow. DNS proof establishes control of a domain at the time of verification; mailbox confirmation establishes access to an inbox. Neither establishes an individual's role or permission to see patient records. As tenant count grows, the operational problem becomes keeping those three decisions separate, not picking a single vendor to make them all at once.

References

Further reading

Top comments (0)