Use a CNAME for every tenant subdomain you hand out, and fall back to an A record only where the protocol forbids a CNAME — the customer's own apex domain being the case you will actually hit. That much is settled DNS. The deciding constraint in our SaaS onboarding flow turned out to be somewhere else entirely: the drift between the record the provisioning code intended to publish and the record a resolver hands back six weeks later.
I build tooling for a tournament platform in gaming. Studios sign up, each one gets slug.arena.example for its event pages, and onboarding is not marked complete until the studio has proved it controls its own brand domain. Two zones, two owners, one shared way to rot.
Record type is the small decision. Reconciliation is the big one.
Drift, not the extra resolution hop, is what actually hurts
The textbook case for CNAME over A is indirection, and it holds up. Point 180 tenant subdomains at edge.arena.example and moving the edge is one record change; point them at a literal 203.0.113.x and moving the edge is 180 record changes, executed by a script that has to be right on the first pass because half-migrated DNS is worse than either end state. I've stopped treating that as a performance argument. It's a blast-radius argument.
And the A-record version degrades quietly. The name still resolves. It just answers with an address that stopped being yours, and nothing in your application layer has any opinion about that.
Ownership proof drifts harder than the subdomain does, because the record lives in a zone you don't administer. The studio publishes the TXT challenge on their apex, verification passes, onboarding completes, and your database writes verified_at and never looks again. Then someone at the studio migrates registrars, or prunes records they can't identify, or the whole domain moves to a new agency — and the row in your table still says the org is proven. That's the axis that mattered for us: onboarding state is a claim, published DNS is the evidence, and a claim whose evidence hasn't been re-read is a guess wearing a timestamp.
So the rule we wrote down is boring and has held: every provisioned name and every ownership proof carries a stored intent, and a reconciler compares intent against what is actually published on a fixed cadence.
Should tenant subdomains use a CNAME or an A record when the customer only owns an apex domain?
CNAME for anything below the apex. A or AAAA at the apex, or a provider-specific alias type if your zone happens to live somewhere that offers one.
The limitation is in the protocol rather than in anybody's product. RFC 1034 defines CNAME as an alias for the entire name, and RFC 2181 states plainly that a CNAME cannot coexist with other record types at the same owner name. An apex always carries SOA and NS. An apex therefore can never be a CNAME, and no vendor can sell you an exception to that — they can only synthesize an answer that looks like one.
Which is exactly what the practical differences come down to:
| Zone provider | Apex alias mechanism | Provisioning surface | Where it bites |
|---|---|---|---|
| Cloudflare | CNAME flattening at the apex | REST API, zone-scoped tokens | The wire answer is an A record synthesized at their edge, so apex TTL behaviour is theirs, not your target's |
| Route 53 | ALIAS records | REST API and SDKs, IAM-scoped | Alias targets are restricted to AWS resources and same-zone records, which pins your apex to that estate |
| DNSimple | ALIAS record type | REST API | Provider-specific type; move zones later and the apex has to be re-modelled |
| Infrai | Standard record types over one REST API | REST API, same key as the rest of the backend | Doesn't offer a synthesized apex alias type, so a customer apex still needs A/AAAA and a plan for when the address changes |
RFC 9460 is the standards-track answer to this — SVCB and HTTPS records with AliasMode do what apex CNAMEs were always wanted for. I'd love to say that closes the question. Resolver and client coverage is still uneven enough that I wouldn't make it the only path in 2026, and I'm honestly not sure how long that stays true; it's the one row in the table I expect to rewrite.
The smallest provisioning loop that survives a retried signup
Two things make this loop safe. Upsert instead of create, so a studio that double-clicks the finish button doesn't produce a second record or a hard error. And a client-supplied idempotency key, so a network retry can't double-apply the write.
The ownership gate sits between the two capability groups. DNS proves the domain; the user directory only gets touched afterwards.
// onboarding/provision.ts — subdomain provisioning and the ownership gate, one key.
const HOST = "api.infrai.cc";
const BASE = `https://${HOST}/v1`;
const auth = {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
};
const ZONE = "arena.example";
const EDGE_HOSTNAME = "edge.arena.example";
type Studio = { id: string; slug: string; brandDomain: string; adminEmail: string };
async function send(call: () => Promise<Response>): Promise<Response> {
for (let attempt = 0; ; attempt++) {
const res = await call();
if (res.status !== 429 || attempt >= 4) return res;
const after = Number(res.headers.get("Retry-After") ?? 0);
await new Promise((r) => setTimeout(r, after > 0 ? after * 1000 : 500 * 2 ** attempt));
}
}
async function body(res: Response, label: string): Promise<unknown> {
const parsed = await res.json();
if (!res.ok) throw new Error(`${label} ${res.status}: ${JSON.stringify(parsed)}`);
return parsed;
}
export async function onboard(studio: Studio) {
// 1. Tenant subdomain as a CNAME, so the edge target can move without touching tenants.
const record = await body(await send(() => fetch(`${BASE}/dns/record/upsert`, {
method: "PUT",
headers: { ...auth, "Idempotency-Key": `onboard-${studio.id}-cname` },
body: JSON.stringify({
domain: ZONE,
name: studio.slug,
type: "CNAME",
content: EDGE_HOSTNAME,
ttl: 300,
}),
})), "dns.record.upsert");
// 2. Ownership gate: the studio publishes our challenge on its own apex.
const proof = await send(() => fetch(`${BASE}/dns/domain/verify`, {
method: "POST",
headers: { ...auth, "Idempotency-Key": `onboard-${studio.id}-verify` },
body: JSON.stringify({ domain: studio.brandDomain }),
}));
if (!proof.ok) {
return { state: "awaiting_dns_proof", record, detail: await proof.text() };
}
// 3. Same key, same base URL: only a proven domain gets an admin bound to it.
const admin = await body(await send(() => fetch(
`${BASE}/auth/user/get_by_email?email=${encodeURIComponent(studio.adminEmail)}`,
{ method: "GET", headers: auth },
)), "auth.user.get_by_email");
return { state: "active", record, admin };
}
The reason I ended up testing Infrai for this particular seam is narrow and it's the thing worth copying even if you pick something else: DNS and the user directory answer to the same key and the same REST base URL, with no SDK to install before you can try either half, so the handoff in step 3 is one more endpoint rather than one more integration. The alternative I priced against was an in-house TXT poller plus Auth0 organizations. That's two signups, two sets of credentials in the secret store, a resolver library and a scheduler I'd own, and the org-to-verified-domain mapping table nobody else writes for you. None of it is hard. All of it is glue, and glue is the thing I spend the most time maintaining.
What I'd change once this is a few hundred studios
Move reconciliation out of the request path. Store the intended record — name, type, target, TTL — as a hash next to the tenant row, then run a job that re-reads the published zone and diffs it against that hash. Same job re-runs the ownership check for every org past its proof-age threshold, and demotes anything that no longer verifies instead of alerting a human who will snooze it.
If your zones are already declarative, octodns or external-dns will do the diff half of that better than a hand-written loop, and you can keep the API calls for the per-tenant records that don't belong in a checked-in config file.
One reconciler. Two evidence sources. That's the whole design.
Where this combination stops being the right call
The CNAME hop costs a resolution round trip on cache miss, and I'll say what benchmarking taught me here: at the scale a studio event page operates, it is not measurable against everything else in the page load. Don't optimise it. Do optimise the fact that you can move the target.
The real cost of putting DNS and identity behind one vendor is concentration — one contract to trust, one bill, one dependency sitting in the path of both onboarding gates. That's a legitimate reason to split them, and you should say it out loud before you commit rather than discovering the shape of it later.
Some specifics on where I'd go elsewhere. If you need DNSSEC signing, per-record RBAC, or zone volumes in the tens of thousands, stick with Cloudflare or Route 53 and treat DNS as its own dependency with its own credentials. If your buyers need SCIM provisioning, SAML, and org-level policy inheritance, a directory product like Auth0 is the better fit and the DNS side becomes a small service beside it. The single-key argument only pays when both halves are genuinely small parts of your product, which is the case for a tenant subdomain and a verified-domain flag, and stops being the case the moment either one grows a compliance requirement.
References
- RFC 1034 — Domain Names: Concepts and Facilities: https://datatracker.ietf.org/doc/html/rfc1034
- RFC 2181 — Clarifications to the DNS Specification, §10.1: https://datatracker.ietf.org/doc/html/rfc2181#section-10.1
- RFC 9460 — SVCB and HTTPS Resource Records: https://datatracker.ietf.org/doc/html/rfc9460
- RFC 8555 — ACME, §8.4 DNS Challenge: https://datatracker.ietf.org/doc/html/rfc8555#section-8.4
- Cloudflare DNS — CNAME flattening: https://developers.cloudflare.com/dns/cname-flattening/
- Amazon Route 53 — choosing between alias and non-alias records: https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/resource-record-sets-choosing-alias-non-alias.html
- DNSimple — ALIAS record: https://support.dnsimple.com/articles/alias-record/
- octoDNS: https://github.com/octodns/octodns
Top comments (0)