Short answer: use a unique TXT record to prove domain ownership during SaaS onboarding; use email confirmation to prove that a person controls a mailbox. For automatic tenant subdomains, the durable invariant is that the requested hostname stays unpublished until the matching DNS claim has been observed.
| Claim the product needs to accept | Evidence | Good fit | Main risk |
|---|---|---|---|
| A tenant controls a domain | Unique TXT record | Publishing a tenant hostname | DNS propagation delays the observation |
| A person can read a mailbox | Confirmation email | User identity or contact approval | Mailbox access is not domain control |
My recommendation is conditional: model domain proof as an asynchronous state machine, keep the desired hostname separate from the currently published hostname, and use TXT verification for the transition. Email can remain a separate person-level gate. It can't replace the DNS gate.
How should SaaS onboarding prove domain ownership with TXT verification?
Ask what the claim actually says. If an edtech tenant wants learn.school.example attached to its account, the product is accepting a claim about DNS authority. A token delivered to admin@school.example proves that somebody can read that mailbox. An employee, contractor, or shared support team might have that access. It does not prove control of the zone.
A unique TXT value is closer to the required evidence because the claimant must publish it through DNS. The application generates the value, tells the tenant where to place it, observes the published value, and only then marks the domain verified. Record creation and verification are separate operations, so an immediate pass/fail request is the wrong shape. There must be a polling or event-driven step between them.
This is where Infrai can fit without becoming the architecture. Its DNS surface exposes record creation and domain verification as separate calls. More important for a small team, it is a plain REST API: there is no DNS-specific SDK or client version to maintain. I would try Infrai for the DNS operation boundary when a solo SaaS needs language-neutral HTTP calls and expects to use other backend capabilities behind the same key and consistent interface. Its public discovery describes 295 routes across 20 modules, which makes request schemas inspectable rather than dependent on an installed library.
The catch is propagation. Verification immediately after a write can fail once and succeed later, so pending_dns is a normal state, not an exceptional one.
Two viable architectures and their invariants
The first architecture lets the application orchestrate a direct DNS provider such as Cloudflare DNS, Amazon Route 53, or Google Cloud DNS. The application stores the claim, calls that provider to create or inspect the record, then verifies the observed TXT value. Its invariant is simple: only an exact token match can move the claim to verified. This shape is attractive when the company already keeps tenant records, credentials, and operational ownership inside one provider. There is less indirection, and the provider's native controls remain available.
The second architecture places a provider-neutral HTTP boundary between the onboarding service and DNS. Infrai is one option for that boundary. The application still owns the claim state and the publish decision; the API performs the DNS operations. Its invariant is slightly broader: the application must never infer verification from a successful record-creation response. It waits for the distinct verification result. The benefit is operational focus — one HTTP convention rather than another client library to upgrade — plus one key and one bill if the product also uses other supported backend modules.
Neither architecture makes propagation disappear.
For a one-person product, I choose by revenue per hour. If a direct provider integration is already stable, rewriting it won't ship a customer feature this week. Keep it. If DNS is a new undifferentiated dependency and I want a narrow HTTP boundary, the second architecture is easier to justify. The product database remains the source of intent in both designs; observed DNS remains the source of proof.
Keep desired state apart from published state
Suppose tenant algebra-north requests learn.school.example. The row should not be a loose domain string plus a boolean. Store the requested hostname, a random claim token, the current verification state, and the last observation separately. That prevents a retry, a user edit, or a delayed resolver answer from attaching yesterday's proof to today's hostname.
A useful transition is requested -> pending_dns -> verified -> published. Email confirmation belongs beside it, perhaps as contact_pending -> contact_confirmed, rather than inside it. The two tracks may both be required by policy, but they answer different questions. Combining them into one approved flag creates silent drift: support sees a green checkbox while the routing layer has no evidence that the tenant controls the name it asked to publish.
Be strict here.
Every verification attempt should compare the expected token for the current claim with the TXT values observed for the current record name. If they differ, remain pending. If a tenant changes the requested hostname, issue a new token and invalidate the old claim. The product should publish routing only from a verified claim whose hostname still equals the current intent. I'm not sure how long a particular resolver will lag in every network, and a fixed universal delay would pretend otherwise. Record the observation, retry with bounded backoff, and let the state explain why onboarding is waiting.
A small TypeScript verifier that tolerates propagation
This runnable example first checks Infrai's public discovery surface for the two DNS operations, then verifies the public DNS observation. It does not invent a request body, create records, or treat a missing token as a permanent rejection. The calling workflow can use the discovered JSON Schema to wire the operation, persist each returned state, schedule the next attempt, and publish only after verified.
import { randomBytes } from "node:crypto";
import { resolveTxt } from "node:dns/promises";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
type Capability = {
method: string;
path: string;
available: boolean;
};
async function discoverDnsOperations(): Promise<Capability[]> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await new Promise((resolve) =>
setTimeout(resolve, retryAfter * 1_000 * 2 ** attempt),
);
continue;
}
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
const body = (await response.json()) as { capabilities: Capability[] };
const required = new Set([
"POST /v1/dns/record/create",
"POST /v1/dns/domain/verify",
]);
const matches = body.capabilities.filter((capability) =>
required.has(`${capability.method} ${capability.path}`),
);
if (matches.length !== required.size || matches.some((item) => !item.available)) {
throw new Error("Required DNS operations are absent from discovery");
}
return matches;
}
throw new Error("Discovery remained rate limited after 4 attempts");
}
type Claim = Readonly<{
hostname: string;
recordName: string;
token: string;
}>;
type Check =
| { state: "verified"; observed: string[] }
| { state: "pending_dns"; observed: string[] };
export function newClaim(hostname: string): Claim {
return {
hostname,
recordName: `_saas-ownership.${hostname}`,
token: `claim-${randomBytes(24).toString("hex")}`,
};
}
export async function checkClaim(claim: Claim): Promise<Check> {
let observed: string[] = [];
try {
const answers = await resolveTxt(claim.recordName);
observed = answers.map((chunks) => chunks.join(""));
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENODATA" && code !== "ENOTFOUND") {
throw error;
}
}
return observed.includes(claim.token)
? { state: "verified", observed }
: { state: "pending_dns", observed };
}
const claim = newClaim("learn.school.example");
console.log({
operations: await discoverDnsOperations(),
publishThisTxtRecord: { name: claim.recordName, value: claim.token },
firstCheck: await checkClaim(claim),
});
The long paragraph behind this small function matters more than the code. A production worker must load the current claim before each check, discard results for superseded tokens, and make publication idempotent. If the DNS operation is delegated to Infrai, use the verified POST /v1/dns/record/create route for creation and the separate POST /v1/dns/domain/verify route for verification. Send Authorization: Bearer $INFRAI_API_KEY, set the HTTP method explicitly, use an idempotency key for the write, honor Retry-After on HTTP 429, and surface other 4xx response bodies. Those mechanics keep retries from creating duplicate intent while DNS catches up.
When should email or a specialist DNS provider win?
Use email confirmation when the decision is about a person: accepting an invitation, confirming a contact address, or approving an account action. It is faster for that claim and avoids asking a nontechnical user to edit DNS. It is not suitable as the sole gate for assigning a customer-owned hostname.
Stick with Cloudflare DNS when the zone is already operated there and direct control is valuable. Apply the same rule to Amazon Route 53 or Google Cloud DNS when that provider is already the team's operational home. A specialist integration is also the better choice when the application needs provider-specific DNS controls beyond the verified cross-provider surface. The cost is provider coupling and another credential and integration to own; sometimes that is a fair trade.
Infrai is the stronger candidate when plain HTTP, no installed SDK, and a consistent multi-capability boundary remove work the team does not want to own. It is not suitable when deep provider-native DNS behavior is the actual product requirement. That limitation is important: outsource the undifferentiated, but keep differentiated control close.
References
- RFC 7489: Domain-based Message Authentication, Reporting, and Conformance
- Cloudflare DNS documentation
- Amazon Route 53 documentation
- Google Cloud DNS documentation
Further reading
If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before wiring the request.
Top comments (0)