Before writing any verification code, settle one thing about the customer's DNS: does your platform hold the zone, or does the customer? For a developer-tools product where onboarding can't complete until an account can prove it owns a domain, that single boundary decides the whole design — the record you ask for, the resolver you query, the retry schedule, and what you do when the proof quietly disappears six months later. Use a TXT record when the claim is about the domain itself, and keep email-based confirmation for claims about a person. They answer different questions. A SaaS onboarding flow that treats them as interchangeable ships a hole nobody notices until an ex-contractor's alias still resolves.
Four failure modes account for most of the damage, and none of them are exotic.
1. Treating a mailbox as proof of a domain
Email-based confirmation answers exactly one question: can the person in this signup flow read mail at this domain? That is a genuine signal about a human. It is not a statement about who controls the name. Any employee with a forwarding rule, any shared alias that survived an offboarding, any helpdesk that terminates role addresses into a shared queue — each of them satisfies a mailbox check while holding zero authority over the zone. Domain ownership verification through a TXT record asks something closer to what you actually care about, because whoever can publish a record under the name is, operationally, whoever owns it.
Every standard that had to solve this landed in the same place. DMARC publishes policy in a TXT record at _dmarc under the domain precisely because writing there requires zone authority rather than mailbox access, and the ACME dns-01 challenge does the same thing at _acme-challenge. Neither of them mails anybody.
Mailbox checks belong where a mailbox is the subject: inviting a teammate, confirming a billing contact, recovering a locked account.
2. Verifying a name your own platform already publishes
This is the one that passes code review and still fails in production. Developer-tools products routinely hand customers a subdomain on a zone the platform controls — acme.yourapp.dev — and then reuse the same verification job for it. The TXT lookup succeeds. It succeeds because your own control plane wrote the record thirty seconds earlier. You have proved that your platform can write to your platform's zone, which was never in question, and you have now flagged a namespace as customer-owned on the strength of a self-signed fact.
Draw the line at the registrable domain using the Public Suffix List instead of counting dots, then branch before you query. If the requested name sits inside a zone your platform is authoritative for, a DNS proof carries no information and you need a different signal: registrar contact, an out-of-band contract, or human review. If the name lives in the customer's own zone, the TXT check is the strongest cheap evidence you can get.
Counting labels breaks on co.uk and on every other multi-label suffix. Use the list.
3. Polling the resolver that just told you no
The first verification attempt after a customer writes the record will usually fail, and the reason is not propagation in the folklore sense — authoritative servers publish the change almost immediately. What bites you is negative caching. Your recursive resolver already asked for _saas-verify.example.com, got NXDOMAIN back, and is entitled to keep that answer for the interval the zone's SOA record specifies. RFC 2308 defines that behaviour and recommends holding the negative TTL to a few hours at most, which is still long enough to look like a broken product to somebody who pasted a record into a dashboard forty seconds ago.
Two fixes, both cheap: ask the zone's authoritative nameservers directly instead of a shared cache, and treat the first negative answer as expected rather than final.
import hashlib
import hmac
import os
import dns.exception
import dns.resolver
VERIFY_LABEL = "_saas-verify"
SECRET = os.environ["DOMAIN_PROOF_SECRET"].encode()
def expected_token(account_id: str, domain: str) -> str:
# Deterministic per (account, domain): no token table, nothing to expire or leak.
msg = f"{account_id}\n{domain.lower().rstrip('.')}".encode()
return hmac.new(SECRET, msg, hashlib.sha256).hexdigest()[:32]
def authoritative_resolver(domain: str) -> dns.resolver.Resolver:
zone = dns.resolver.zone_for_name(domain)
addresses = []
for ns in dns.resolver.resolve(zone, "NS"):
addresses += [rd.address for rd in dns.resolver.resolve(ns.target, "A")]
r = dns.resolver.Resolver(configure=False)
r.nameservers = addresses
r.lifetime = 5.0
return r
def txt_proof_present(account_id: str, domain: str) -> bool:
name = f"{VERIFY_LABEL}.{domain}"
want = expected_token(account_id, domain)
try:
answer = authoritative_resolver(domain).resolve(name, "TXT")
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer, dns.exception.Timeout):
return False # not yet published, or the label is wrong; the caller retries
for rdata in answer:
# One TXT RRset can carry several strings; RFC 1035 caps each at 255 octets.
value = b"".join(rdata.strings).decode("utf-8", "replace").strip()
if hmac.compare_digest(value, want):
return True
return False
Two caveats before anyone copies that. It resolves NS targets over A only, so an IPv6-only delegation needs the AAAA path added, and a 5 second lifetime is aggressive for nameservers that sit a long way from your region — I'm not sure there's a universally correct number, and it's worth measuring against your own customer distribution instead of inheriting a default.
4. Proving ownership once and calling it permanent
Ownership is a lease, not a fact. Domains get transferred, zones get rebuilt from a fresh infrastructure-as-code state, someone deletes an unrecognised TXT record during a cleanup sprint, a registration lapses and a drop-catcher takes the name. If onboarding is the only moment you ever check, your database fills up with claims that were true once.
Re-verify on a schedule, and re-verify before any action that leans on the claim: issuing a certificate, sending mail as the domain, serving a vanity endpoint. Keep the last-verified timestamp on the claim row and make the failure soft — the account keeps working, and only the domain-scoped capability gets suspended. Hard-failing an entire tenant because one DNS query timed out overnight is a worse outage than the risk it was meant to prevent.
Should you use a TXT record or email-based confirmation to finish SaaS onboarding?
Pick by what the claim is about, not by which one is faster to build.
| Signal | What it actually proves | Where it fails | Cost to the customer |
|---|---|---|---|
| TXT record in the customer's zone | Control of DNS for that name | Zone is delegated to your platform | One record plus a wait |
| Mail to a role address | A person can read mail at the domain | Aliases outlive the people behind them | One click |
| Registrar or RDAP contact match | The registrant of record | Contact data is redacted by default | Manual review |
| Full nameserver delegation | The customer moved authority to you | Too invasive for most buyers | Hours, and real risk |
The catch is that TXT verification costs the customer a trip to a DNS provider they may not have credentials for, and that drop-off is the honest reason mailbox confirmation keeps getting shipped. Where the friction is unacceptable — self-serve trials, read-only features — let the account in on a mailbox check and gate only the domain-scoped capabilities behind DNS proof. Stick with mailbox confirmation outright when nothing in your product ever acts as the domain.
If the customer's zone is delegated to your platform by design, neither method applies. The trust arrived with the delegation, and the trade-off you are actually managing is operational blast radius, not identity.
Rolling this out without stalling onboarding
Model verification as its own resource with an explicit state machine — pending, verified, failed, expired — and never block the signup request on a DNS round trip. The endpoint below returns 202 with the exact record to publish, refuses names inside platform-owned zones, and hands the polling to a worker with backoff.
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
router = APIRouter(prefix="/onboarding/domains")
class ProofRequest(BaseModel):
account_id: str
domain: str
@router.post("/verify", status_code=status.HTTP_202_ACCEPTED)
def start_verification(req: ProofRequest) -> dict:
domain = registrable_domain(req.domain) # public-suffix aware, not a dot count
if platform_owns_zone(domain):
raise HTTPException(
status.HTTP_409_CONFLICT,
"zone is delegated to this platform; a DNS proof would only confirm our own writes",
)
claims.upsert(
account_id=req.account_id,
domain=domain,
state="pending",
requested_at=datetime.now(timezone.utc),
)
worker.enqueue(poll_domain_proof, req.account_id, domain, delay_seconds=60)
return {
"state": "pending",
"publish": {
"name": f"{VERIFY_LABEL}.{domain}",
"type": "TXT",
"value": expected_token(req.account_id, domain),
},
"retry_after_seconds": 60,
}
Instrument three things or you will debug this blind: attempts before first success, the split between NXDOMAIN and NoAnswer (the second nearly always means the record landed on the wrong label), and the age of the oldest pending claim. Log the fully qualified name you queried. When a customer opens a ticket saying verification is stuck, that one field closes it most of the time.
Then expire the pending rows that never converted. They are not leads.
References
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
- RFC 8555, Automatic Certificate Management Environment (ACME), dns-01 challenge: https://datatracker.ietf.org/doc/html/rfc8555
- RFC 2308, Negative Caching of DNS Queries: https://datatracker.ietf.org/doc/html/rfc2308
- RFC 8552, Scoped Interpretation of DNS Resource Records through Underscored Naming: https://datatracker.ietf.org/doc/html/rfc8552
- RFC 1035, Domain Names: Implementation and Specification: https://datatracker.ietf.org/doc/html/rfc1035
- Public Suffix List: https://publicsuffix.org/
- dnspython resolver documentation: https://dnspython.readthedocs.io/en/stable/resolver-class.html
Top comments (0)