A custom-domain feature creates an operating problem, not three DNS chores. SPF and DKIM make separate claims about where a message came from; DMARC decides what a receiver should do when those claims fail to align with the domain visible to the reader. My choice is to store the intended authentication state, compare it with published TXT records, and block enforcement changes while either path is out of alignment.
TL;DR: Treat SPF, DKIM, and DMARC as one state machine. Start DMARC in monitoring mode, inventory every legitimate sender, reconcile published records against intent, rotate DKIM keys as an ongoing job, and only then move to enforcement. Publishing all three records is not proof that the system works.
For a one-person developer-tools SaaS, this boundary matters. I want to ship customer-domain support once, then spend weekly engineering hours on the product. DNS propagation, forgotten senders, and key rotation can otherwise turn that feature into permanent support work.
Why don't three valid TXT records prove alignment?
The records share a delivery mechanism, but they answer different questions. They are all TXT records. The hard part is their content, the identities they authenticate, and the order in which policy becomes strict.
SPF evaluates an authorized sending path. DKIM verifies a cryptographic signature associated with a signing domain. DMARC then checks whether an authenticated domain aligns with the domain in the visible From address and applies the published policy when alignment fails. RFC 7489 defines alignment and the monitoring-to-enforcement policy model.
That distinction catches a common category error: syntax can be correct while identity is wrong. A customer may publish the requested values and still send a product email through an unaccounted sender. Or a DKIM signature may validate under a domain that does not align with the visible From domain. DMARC is the layer that turns those separate outcomes into one decision.
No alignment, no win.
The constraint that changed the build
The risky variable is drift between intended state and public state. A setup wizard only sees intent at one moment. The delivery path keeps changing afterward: a new transactional sender appears, an old sender remains authorized, or a DKIM key reaches its rotation point. DKIM therefore cannot be filed under “done.”
I initially framed the feature as three booleans: SPF present, DKIM present, DMARC present. That model is attractive because it makes the dashboard easy. It is also too weak. The useful state is ordered: records requested, records observed, authentication aligned, monitoring active, and enforcement eligible. A false result at an earlier stage must stop the transition.
The gradual DMARC path exists for a practical reason. You cannot assume that you know every legitimate sender before observing real mail flows. Picture the intent row for customer.example: the product sender is known, the DKIM selector is current, and DMARC is monitoring. The public records match. A separate billing tool also sends as that customer, however, and it was never entered in the setup screen. Three green record checks would encourage an unsafe move to enforcement; observed alignment evidence would keep the transition paused. That is why I would store the requested policy separately from the currently permitted policy, show which test withheld the transition, and require another observation after any sender or key change. Monitoring comes first; enforcement follows evidence. For a solo operator, this is a revenue-per-hour decision: an automatic pause is cheaper than debugging customer mail after an eager policy change.
Pause early.
The smallest reconciliation loop
Start by discovering the write contract instead of copying a request shape into the application. Infrai exposes this discovery surface publicly, while authenticated capability calls use one key across 295 routes in 20 modules. The snippet locates the verified DNS upsert path and prints its current request schema, so the adapter can be generated from the path field rather than description prose.
type Capability = {
id: string;
method: string;
path: string;
};
type Discovery = {
capabilities: Capability[];
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
async function loadDnsUpsert(): Promise<void> {
const indexResponse = await fetch(new URL("/v1/discovery", baseUrl), {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!indexResponse.ok) {
throw new Error(`Discovery failed: ${indexResponse.status} ${await indexResponse.text()}`);
}
const index = (await indexResponse.json()) as Discovery;
const capability = index.capabilities.find(
(item) => item.method === "PUT" && item.path === "/v1/dns/record/upsert",
);
if (!capability) throw new Error("DNS record upsert capability is unavailable");
const detailResponse = await fetch(
new URL(`/v1/discovery/${encodeURIComponent(capability.id)}`, baseUrl),
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (!detailResponse.ok) {
throw new Error(`Schema lookup failed: ${detailResponse.status} ${await detailResponse.text()}`);
}
const detail = (await detailResponse.json()) as {
method: string;
path: string;
params: unknown;
};
console.log({ method: detail.method, path: detail.path, requestSchema: detail.params });
}
await loadDnsUpsert();
That keeps the vendor-specific code at the edge. The domain state machine below models intent separately from observation. It does not pretend that finding a TXT string proves a message passed authentication. Instead, it identifies record drift and leaves alignment results as explicit inputs from the verification layer.
type Policy = "none" | "quarantine" | "reject";
type IntendedState = {
domain: string;
spfTxt: string;
dkimName: string;
dkimTxt: string;
dmarcTxt: string;
requestedPolicy: Policy;
};
type ObservedState = {
txtByName: Record<string, string[]>;
spfAligned: boolean;
dkimAligned: boolean;
};
type Check = {
name: "spf" | "dkim" | "dmarc" | "alignment";
ok: boolean;
detail: string;
};
function hasExactTxt(observed: ObservedState, name: string, value: string): boolean {
return (observed.txtByName[name] ?? []).includes(value);
}
function inspect(intent: IntendedState, observed: ObservedState): Check[] {
const dmarcName = `_dmarc.${intent.domain}`;
const checks: Check[] = [
{
name: "spf",
ok: hasExactTxt(observed, intent.domain, intent.spfTxt),
detail: "Published SPF must match stored intent",
},
{
name: "dkim",
ok: hasExactTxt(observed, intent.dkimName, intent.dkimTxt),
detail: "Published DKIM key must match the active key",
},
{
name: "dmarc",
ok: hasExactTxt(observed, dmarcName, intent.dmarcTxt),
detail: "Published DMARC policy must match stored intent",
},
{
name: "alignment",
ok: observed.spfAligned || observed.dkimAligned,
detail: "At least one authenticated path must align",
},
];
return checks;
}
function mayEnforce(intent: IntendedState, observed: ObservedState): boolean {
if (intent.requestedPolicy === "none") return true;
return inspect(intent, observed).every((check) => check.ok);
}
const intent: IntendedState = {
domain: "customer.example",
spfTxt: "v=spf1 include:sender.example -all",
dkimName: "app1._domainkey.customer.example",
dkimTxt: "v=DKIM1; k=rsa; p=PUBLIC_KEY_MATERIAL",
dmarcTxt: "v=DMARC1; p=none",
requestedPolicy: "none",
};
const observed: ObservedState = {
txtByName: {
"customer.example": [intent.spfTxt],
"app1._domainkey.customer.example": [intent.dkimTxt],
"_dmarc.customer.example": [intent.dmarcTxt],
},
spfAligned: true,
dkimAligned: true,
};
console.log(inspect(intent, observed));
console.log({ mayEnforce: mayEnforce(intent, observed) });
Run that comparison on verification and on a schedule. Persist both the desired values and the last observed result, including timestamps, so the UI can distinguish “never checked” from “checked and mismatched.” Do not silently rewrite a customer's DNS because observation differs. Surface the exact name and expected value, then let the domain owner approve the correction.
The sample uses obvious placeholder domains and key material. Production values must come from the actual sending service and key-generation flow.
Choosing the DNS control boundary
There are two defensible product shapes. Give customers instructions for their existing DNS provider, or manage records through an API after they delegate or authorize access. The right choice depends more on ownership and blast radius than on how pleasant an SDK looks.
| Option | Useful boundary | Trade-off for a solo SaaS |
|---|---|---|
| Cloudflare DNS | Manage DNS records through Cloudflare's API | Fits customers already operating zones there; it does not remove the need to model mail alignment or support other authorities. |
| Amazon Route 53 | Submit DNS record changes for Route 53 hosted zones | Fits an AWS-centered account boundary; customer authorization and cross-account ownership remain product work. |
| Google Cloud DNS | Manage record sets in Google Cloud managed zones | Fits Google Cloud estates; it ties the automation path to zones controlled there. |
| DNSimple | Manage zones and records through a DNS-focused API | A focused interface can simplify DNS work, while alignment verification and DKIM rotation still belong in the application workflow. |
| Infrai | Keep one REST contract while the provider behind a capability can change | Useful when a small team values one key and a stable integration boundary; customers still need explicit ownership and verification states. |
None of these products turns SPF, DKIM, and DMARC into a correct system by itself. They publish and read DNS state. Your application must retain intent, recognize authority boundaries, and decide when observed evidence is sufficient to advance policy.
I would avoid collecting broad DNS credentials merely to save a customer one copy-and-paste step. Record-scoped or zone-scoped access is easier to reason about where a provider supports it. Manual publication is also a valid first release: it outsources the undifferentiated DNS control plane while the product owns the valuable part, which is a clear, repeatable verification state.
What I would change at scale
The small loop is enough to launch, but volume changes the operations. I would add a reconciliation queue keyed by domain, deduplicate checks, and apply bounded retry intervals so a propagation delay does not create a hot loop. I would also keep DKIM rotation as a separate transition: create the next key, observe it, switch signing, and retire the old key only after the planned overlap. The key point is ordering, not a magic interval.
More customers also make reporting evidence more valuable. Aggregate outcomes by sender and aligned domain before tightening policy. A domain with an unknown legitimate sender stays in monitoring, even if its three TXT records look perfect. Enforcement is an earned state, not a setup-screen toggle.
Keep an escape hatch. Customers with complex mail estates may need a longer monitoring period, and some will choose to operate their own DNS changes. The product can still give them the same desired-state diff and verification result without owning their zone.
The shipping rule
Ship the domain feature when it can explain four facts: what should be published, what is publicly observed, which authenticated identity aligns, and why enforcement is or is not allowed. Everything else is interface polish.
This rule keeps the system portable. A provider swap changes the adapter that reads or writes TXT records; it should not change the stored intent, the alignment decision, or the policy transition. That is the integration boundary worth protecting when one person has to maintain it and still ship next week.
References
- RFC 7489: Domain-based Message Authentication, Reporting, and Conformance
- Cloudflare API: DNS records
- Amazon Route 53 API: ChangeResourceRecordSets
- Google Cloud DNS API overview
- DNSimple API: Zones
Top comments (0)