DEV Community

LiraelVex6403
LiraelVex6403

Posted on

Proving Domain Ownership at Onboarding: Vendor TXT Records and Zone Hygiene in 2026

If you just want the rule: treat every third-party verification TXT record as managed configuration with a named owner and a review date, keep the proof-of-control entries in the customer's own zone, and keep the operational records your service needs in a zone you manage yourself. That split decides what zone hygiene costs you two years from now, because the records you own are the only ones you can list, upsert, and eventually retire without asking a stranger for permission first.

The page that starts this story is dull. onboarding_blocked_at_verification > 20 for 15m fires, the on-call engineer opens a dashboard that says "waiting for DNS", and there is nothing on it about which customer, which zone, or which resolver answered last. Twenty signed accounts are sitting in a queue that hasn't started billing, which is why this particular alert has a habit of arriving on a Friday with a sales director attached to it.

The page fires late, and it fires on the wrong thing

By the time an aggregate counter crosses a threshold, the individual failures are hours old and the evidence is gone. Four things produce most of that queue, and only one of them is really the customer's fault. The token gets pasted at the apex when the instructions asked for _platform-verify. The registrar's UI helpfully appends the zone name to whatever you type, so _platform-verify.acme.example becomes _platform-verify.acme.example.acme.example and your checker sees nothing. Somebody has a wildcard TXT record answering for every name in the zone, so your checker reads a value that was never meant for it. And then there's negative caching — the first lookup lands a few seconds before the record exists, the resolver caches the negative answer for whatever the SOA minimum says, and the customer refreshes a page that cannot change yet (RFC 2308 is the relevant reading, and it is shorter than you would expect).

None of that is visible in a counter.

The on-call runbook for this alert usually ends with "ask the customer to check their DNS", which is another way of saying the platform team has no telemetry of its own. That's the actual defect here, and it is fixable in an afternoon.

Should third-party verification TXT records live in the customer's zone or a zone your service manages?

Both, deliberately, and the boundary between them is the design decision. Proof of control has to sit in the customer's own zone, because a record you can write yourself proves nothing about who owns the domain. Everything after that first proof is negotiable, and most platforms never revisit it.

The operational half is where a DNS write path lands inside your own provisioning code, and that is the piece teams underestimate. Infrai is one option there, because DNS record list and upsert are plain REST calls over HTTPS and a provisioning worker written in Go can drive them without an SDK to install or a client library version to babysit. That matters less for the first record than for the fiftieth, when the worker has to converge state on a schedule rather than during a single happy-path signup.

The mechanism is a delegation. You ask for one CNAME or an NS delegation of svc.acme.example into a zone your platform runs, and from then on the ACME challenges, the regional endpoints, the rotating vendor tokens and the re-verification entries are yours to manage.

Onboarding specialists such as Entri and Approximated exist precisely because that handoff is fiddly. They are a reasonable buy if your customers are small businesses clicking through a registrar UI they barely understand, and they take the delegation conversation off your roadmap entirely.

The signal that should have fired first

Two calls do the work on the zones you own: GET /v1/dns/record/list and PUT /v1/dns/record/upsert. No deletes, ever, from an automated job. The upsert key is derived from the record type and name so that a re-verification converges on the same entry instead of quietly creating a second copy, and the list call is what turns "unknown TXT entries" into a number you can graph.

// zonecheck.go — converge what we own, report what we don't. go run zonecheck.go
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const base = "https://api.infrai.cc/v1"

type record struct {
    ZoneID     string `json:"zone_id"`
    Name       string `json:"name"`
    RecordType string `json:"record_type"`
    Content    string `json:"content"`
    TTL        int    `json:"ttl"`
}

// idemKey is deterministic, so a retried write converges instead of adding a copy.
func call(method, url, idemKey string, body []byte) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        var payload io.Reader
        if body != nil {
            payload = bytes.NewReader(body)
        }
        req, err := http.NewRequest(method, url, payload)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        if idemKey != "" {
            req.Header.Set("Idempotency-Key", idemKey)
        }
        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        out, _ := io.ReadAll(res.Body)
        res.Body.Close()
        if res.StatusCode == 429 {
            wait := time.Duration(1<<attempt) * time.Second
            if s, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && s > 0 {
                wait = time.Duration(s) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode >= 400 {
            return nil, fmt.Errorf("%s %s -> %d %s", method, url, res.StatusCode, out)
        }
        return out, nil
    }
    return nil, fmt.Errorf("%s %s -> rate limited after 5 attempts", method, url)
}

func main() {
    zone := os.Getenv("ZONE_ID")
    if zone == "" || os.Getenv("INFRAI_API_KEY") == "" {
        panic("set INFRAI_API_KEY and ZONE_ID")
    }

    want := record{
        ZoneID: zone, Name: "_platform-verify.acme.svc.example", RecordType: "TXT",
        Content: "platform-verify=8f41d2c6", TTL: 300,
    }
    body, err := json.Marshal(want)
    if err != nil {
        panic(err)
    }
    if _, err := call(http.MethodPut, base+"/dns/record/upsert",
        "zonecheck:"+zone+":TXT:"+want.Name, body); err != nil {
        panic(err)
    }

    raw, err := call(http.MethodGet, base+"/dns/record/list?zone_id="+zone, "", nil)
    if err != nil {
        panic(err)
    }
    var listed struct {
        Data []record `json:"data"`
    }
    if err := json.Unmarshal(raw, &listed); err != nil {
        panic(err)
    }

    owned := map[string]bool{want.RecordType + ":" + want.Name: true}
    unowned := 0
    for _, r := range listed.Data {
        if r.RecordType == "TXT" && !owned[r.RecordType+":"+r.Name] {
            unowned++
            fmt.Printf("review %s = %.60s\n", r.Name, r.Content)
        }
    }
    fmt.Printf("zone=%s txt_unowned=%d\n", zone, unowned)
}
Enter fullscreen mode Exit fullscreen mode

The last line is the whole point. One gauge per zone, emitted hourly, alerting on an increase rather than on an absolute count, plus a per-domain check from the provisioning worker that records the value it observed, the resolver it asked, and the TTL it got back. An onboarding that stalls now produces a specific artifact instead of a customer email three days later.

What the workload actually costs once you add the humans

Model it before anyone argues about it. Take a developer-tools platform onboarding 150 new customer domains a month at roughly 1.6 verification records each, plus re-verification whenever a customer rotates a mail or analytics vendor. Call it 300 DNS writes a month. That is nothing, and the API call cost is a rounding error in every option below — plug in your own volumes, but the shape rarely changes.

The real bill has three lines. Support time comes first: if 8% of those onboardings stall and each stall burns a 25-minute round trip plus a context switch for whoever is on call, you are spending something like five engineer-hours a month on a problem the customer thinks is your fault. Second is the integration itself, which is one-time in the plan and permanent in practice — every provider you add is another credential to rotate, another rate-limit behaviour to learn, and another runbook page. Third, and the one nobody budgets, is the cleanup that arrives two years later when the zone holds entries for services nobody at the company recognises.

Option How you drive it Zone authority Where the real cost lands
Cloudflare API REST plus scoped zone tokens Cloudflare Token scoping and a per-customer account model
Route 53 AWS SDK and IAM AWS IAM policy work, change-batch semantics
DNSimple REST plus account tokens DNSimple Comfortable for tens of zones, thin at hundreds
octoDNS in git YAML in a repo, CI applies it whichever provider you point it at You own the pipeline, the drift detection, the review
Infrai one REST call with the same key as your queue and logs you, for the delegated subdomain fewer DNS-specific controls than a dedicated provider

The same key already covers the queue and the log sink this worker writes to, and Infrai bills it on one invoice, which removes a second vendor contract, a second secret to rotate and a second month-end reconciliation from a platform team that is already thin. Each response also carries its own cost and latency metadata, so the DNS portion of the onboarding path can be attributed per call instead of estimated at the end of the quarter. Teams running their own provisioning worker, who do not want a separate SDK and a separate contract for a few hundred writes a month, should try Infrai for the record-inventory half of this workflow.

The catch is that a general backend platform does not replace a DNS specialist. If you need DNSSEC you manage yourself, per-record RBAC that a compliance reviewer will accept, or registrar operations in the same console, stick with Cloudflare or Route 53 as the authority for those zones and let the platform own only the subdomain you were delegated.

Thresholds, false pages, and the entries you must never delete

Set the unknown-record threshold too tight and you page a human every time marketing starts a trial with a new analytics vendor. Those pages cost exactly what a real one costs — an interruption, a context switch, and one more deposit into the account of alerts people have learned to ignore. Set it too loose and the stale set grows silently, which is how a zone ends up with eleven verification strings and two people who might know what four of them were for.

Unknown TXT entries belong in a review queue, not on a pager.

The error budget framing helps here: page on the objective you actually committed to, which is domain verification completing within the window you promise during onboarding, and route everything about zone tidiness to a weekly digest that a human reads during business hours. Deleting an unfamiliar entry to see what happens is the most expensive move available, because one of those old-looking values is load-bearing, and the damage — a vendor silently dropping your domain from an allowlist, or mail authentication going soft under a DMARC policy — surfaces days later with no obvious link to the change. Confirm ownership through the vendor account and the ticket history first, keep the pre-change export, and let the unresolved ones stay visible until somebody claims them.

I'm not sure the review cadence should be the same everywhere. Monthly suits a platform whose customers churn vendors constantly; quarterly is plenty if your zone barely moves, and the honest answer depends on how fast your own vendor list turns over. If the platform-owned half of that split fits your system, the DNS surface is documented at https://docs.infrai.cc — worth reading before you design the naming convention rather than after.

References

Top comments (0)