DriversRecommendedOutdated drivers can make a good PC feel brokenScan driver issues before chasing fixes manually.Scan NowOctober DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsSlow PC?RecommendedPC slow today? Run a repair scan before it gets worseResolve common Windows issues and optimize system performance.Scan Now×
Skip to content
World desk10 min

Build an SPF, DKIM & DMARC Checker API with Node.js

A Node.js build guide for an API that checks SPF, DKIM and DMARC DNS records, parses chunked TXT answers, handles DNS errors separately, and states what DNS-only checks cannot prove.
Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

You can build this API with Node’s promise-based DNS module. Query TXT records at the domain for SPF, at _dmarc.<domain> for DMARC, and at <selector>._domainkey.<domain> for DKIM. Each TXT answer comes back as arrays of character strings, so you must join each record’s chunks before parsing. Keep DNS failures separate from missing records. The endpoint can report what a domain publishes, but it cannot prove that a real email passed SPF or DKIM.

What a DNS-only checker can and cannot prove

Before writing code, decide what the API promises. A checker that only reads DNS answers three different questions, and none of them is the same as evaluating a message:

  • SPF: the domain publishes a record that begins with the v=spf1 version marker. SPF authorization is decided per sending IP address and SMTP identity, so a DNS-only result cannot say whether a particular server was allowed to send.
  • DKIM: a key record exists for one selector. Validating that key in DNS is not the same as validating a signature on a message, which needs the signed message and its headers.
  • DMARC: a policy record exists for the domain or its organizational domain. A published policy does not tell you whether a given message aligned or passed.

The API should say this in its response, not only in documentation. Label results as published configuration, and reserve “pass” and “fail” language for an evaluator that receives the sending IP, the envelope sender, or the signed message.

One privacy point also matters for a public service. Querying SPF data sends DNS queries that reach infrastructure run for the domain owner. In RFC 7208, Section 11.6, Scott Kitterman, one of the RFC’s authors, puts it plainly: “Checking SPF records causes DNS queries to be sent to the domain owner.” Your API will generate that traffic for every domain a user submits, so it needs the rate limits described later.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Where each record lives

All three checks use TXT records, but each is published at a different name. Getting the name right is the most common source of false “missing” results.

Check DNS name queried What the record identifies Input you need
SPF <domain> (the apex) Hosts authorized to send for the domain Domain only for publication checks; sending IP and identity for real SPF evaluation
DKIM <selector>._domainkey.<domain> The public key for one signing selector Domain and selector, usually taken from the s= tag of a DKIM-Signature header
DMARC _dmarc.<domain> The domain’s policy and reporting settings Domain; the organizational domain if you implement fallback

There is no universal DKIM record at the domain level. A domain can use several selectors at once, and you only find a selector by reading it from a message or by guessing common names. A miss on a guessed selector means only that the name did not return a key, not that the domain lacks DKIM.

Reading TXT answers in Node.js

The Node.js DNS documentation for v26.3.1 describes resolveTxt() as returning a two-dimensional array. Each inner array is one TXT record, and its entries are that record’s character strings. A long DKIM key, for example, is usually split into several strings of at most 255 bytes each. Joining the chunks of one record with no separator reconstructs the value. Joining across records would merge unrelated policies into one string, which is wrong.

Here is the difference in practice. Suppose one TXT record is stored as two chunks, "v=DMARC1; p=qu" and "arantine; rua=mailto:[email protected]". The correct value is v=DMARC1; p=quarantine; rua=mailto:[email protected]. Joining it with a space would produce p=qu arantine, which fails the policy check.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Command-line tools can mislead you here. dig +short TXT prints each chunk in its own quotes, separated by spaces, so you read the same record as several quoted pieces. Use it to compare results, but parse the Node output in your code.

Map DNS errors to states, do not collapse them

A rejected promise from resolveTxt() carries a Node error code. Treat these as different outcomes:

Node error code Meaning State to report
ENODATA The name exists but has no TXT data no_txt_data
ENOTFOUND The name does not exist name_not_found
EREFUSED The resolver refused the query refused
ETIMEOUT No answer arrived within the resolver’s timeout and retries timeout
ESERVFAIL The resolver reported a server failure server_failure

The last three are not evidence that a record is absent. If the answer is a timeout, the honest response is “could not determine,” and your API should say so. Only a successful answer without a matching record supports “absent.”

The checker module

The module below uses Resolver from node:dns/promises so you can set a timeout and retry count per resolver instance. It returns raw values with every parsed finding, which lets users see what you matched. Save it as an ES module (for example check-email-dns.mjs, or use "type": "module" in package.json).

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import { Resolver } from 'node:dns/promises';

const resolver = new Resolver({ timeout: 2000, tries: 2 });

class ValidationError extends Error {}

// ASCII host names only. Convert internationalized names to punycode before calling.
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?.)+[a-z]{2,63}$/;
const SELECTOR_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/;
const B64_RE = /^[A-Za-z0-9+/]+=*$/;

const DNS_STATES = {
  ENODATA: 'no_txt_data',
  ENOTFOUND: 'name_not_found',
  EREFUSED: 'refused',
  ETIMEOUT: 'timeout',
  ESERVFAIL: 'server_failure',
};

function normalizeDomain(input) {
  const domain = String(input ?? '').trim().toLowerCase().replace(/.$/, '');
  if (!DOMAIN_RE.test(domain)) throw new ValidationError('domain is not a valid ASCII host name');
  return domain;
}

function normalizeSelector(input) {
  const selector = String(input ?? '').trim().toLowerCase();
  if (!SELECTOR_RE.test(selector)) throw new ValidationError('selector is not a valid DNS label sequence');
  return selector;
}

// Returns raw values and a state. Never throws for DNS conditions.
async function lookupTxt(name) {
  try {
    const answers = await resolver.resolveTxt(name);
    // Each answer is one TXT record made of character-string chunks.
    return { state: 'answer', name, records: answers.map((chunks) => chunks.join('')) };
  } catch (err) {
    return { state: DNS_STATES[err.code] ?? 'lookup_failed', name, records: [], code: err.code ?? null };
  }
}

function parseTags(record) {
  const tags = {};
  for (const part of record.split(';')) {
    const eq = part.indexOf('=');
    if (eq === -1) continue;
    tags[part.slice(0, eq).trim().toLowerCase()] = part.slice(eq + 1).trim();
  }
  return tags;
}

const SPF_VERSION = /^v=spf1(s|$)/i;

function checkSpf(lookup) {
  if (lookup.state !== 'answer') return { state: lookup.state, code: lookup.code };
  const matches = lookup.records.filter((r) => SPF_VERSION.test(r.trim()));
  if (matches.length === 0) return { state: 'absent' };
  if (matches.length > 1) return { state: 'multiple_records', raw: matches };
  const terms = matches[0].trim().split(/s+/).slice(1);
  const all = terms.find((t) => /^[+-?~]?all$/i.test(t)) ?? null;
  return { state: 'published', raw: matches[0], allMechanism: all, termCount: terms.length };
}

async function checkDkim(domain, selector) {
  const name = `${selector}._domainkey.${domain}`;
  const lookup = await lookupTxt(name);
  if (lookup.state !== 'answer') return { name, state: lookup.state, code: lookup.code };
  const candidates = lookup.records.filter((r) => {
    const tags = parseTags(r);
    return 'v' in tags || 'p' in tags;
  });
  if (candidates.length === 0) return { name, state: 'absent' };
  if (candidates.length > 1) return { name, state: 'multiple_records', raw: candidates };
  const tags = parseTags(candidates[0]);
  if (!('p' in tags)) return { name, state: 'no_key_tag', raw: candidates[0] };
  if (tags.p === '') return { name, state: 'revoked', raw: candidates[0] };
  if (!B64_RE.test(tags.p)) return { name, state: 'malformed_key', raw: candidates[0] };
  // RFC 6376 defaults the key type to rsa when k= is absent.
  return { name, state: 'key_published', keyType: tags.k ?? 'rsa', raw: candidates[0] };
}

const DMARC_VERSION = /^v=DMARC1s*(;|$)/i;
const NO_RECORD_STATES = new Set(['absent', 'no_txt_data', 'name_not_found']);

async function checkDmarcAt(domain) {
  const lookup = await lookupTxt(`_dmarc.${domain}`);
  if (lookup.state !== 'answer') return { name: lookup.name, state: lookup.state, code: lookup.code };
  const matches = lookup.records.filter((r) => DMARC_VERSION.test(r.trim()));
  if (matches.length === 0) return { name: lookup.name, state: 'absent' };
  if (matches.length > 1) return { name: lookup.name, state: 'multiple_records', raw: matches };
  const tags = parseTags(matches[0]);
  const policy = tags.p?.toLowerCase();
  if (!['none', 'quarantine', 'reject'].includes(policy)) {
    return { name: lookup.name, state: 'invalid_policy', raw: matches[0] };
  }
  return { name: lookup.name, state: 'published', policy, subdomainPolicy: tags.sp ?? null, raw: matches[0] };
}

async function checkDmarc(domain, organizationalDomain) {
  const own = await checkDmarcAt(domain);
  if (!NO_RECORD_STATES.has(own.state) || !organizationalDomain || organizationalDomain === domain) {
    return { ...own, source: domain, fallback: false };
  }
  const org = await checkDmarcAt(organizationalDomain);
  return { ...org, source: organizationalDomain, fallback: true };
}

export async function checkEmailAuthDns(domainInput, { selector, organizationalDomain } = {}) {
  const domain = normalizeDomain(domainInput);
  const sel = selector ? normalizeSelector(selector) : null;
  const orgDomain = organizationalDomain ? normalizeDomain(organizationalDomain) : null;

  const [spfLookup, dkim, dmarc] = await Promise.all([
    lookupTxt(domain),
    sel ? checkDkim(domain, sel) : Promise.resolve({ state: 'selector_required' }),
    checkDmarc(domain, orgDomain),
  ]);

  return {
    domain,
    checkedAt: new Date().toISOString(),
    scope: 'dns_publication_only',
    spf: checkSpf(spfLookup),
    dkim,
    dmarc,
  };
}

The organizationalDomain option is deliberate. Deriving the organizational domain requires a Public Suffix List, because a naive “last two labels” rule gets country-code and multi-label suffixes wrong. Compute it with a maintained PSL library in your application and pass it in.

Interpreting the results

Each check returns a state. The table below lists what each state means and how to present it. Show the raw value alongside every state so users can compare it with their own DNS.

Check State Meaning
SPF published One record begins with v=spf1. Check allMechanism: a missing all term is a configuration finding, not a DNS error.
SPF multiple_records More than one SPF record exists at the apex. RFC 7208 treats this as an error, so a receiver may return a permanent error.
DKIM key_published A parseable key tag exists for the selector. The key has not been used to verify any message.
DKIM revoked The record has an empty p= tag, which RFC 6376 uses to indicate a revoked key.
DKIM malformed_key The p= value contains characters outside base64. Check for a chunk-joining error before reporting this to the user.
DMARC published A valid v=DMARC1 record with a p= value of none, quarantine, or reject. The source field shows whether it came from fallback.
Any timeout, server_failure, refused Undetermined. Do not report as absent.

For DMARC, the discovery procedure is defined in the current standard, RFC 9989 (2026), which supersedes RFC 7489. Consult RFC 9989 for current discovery rules, and check the errata before you depend on edge cases. The older text is still available at RFC 7489. The module’s fallback only queries the organizational domain after the record at the domain name is absent. If a lookup fails with a timeout, it does not fall back.

For SPF, the record is the input to evaluation rather than the evaluation itself. RFC 7208 limits the number of DNS-querying mechanisms an evaluator may follow, and a full SPF result requires walking include, redirect, a, and mx terms with the sending IP. The checker should list those terms as data and leave evaluation to a separate component that receives the IP address and sender.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Expose the checker over HTTP

The server below is intentionally small. It accepts one GET route, returns JSON, and maps validation errors to 400. Save it next to the module as server.mjs and import checkEmailAuthDns from the module above, adding export is already done. Add the import line at the top:

import { createServer } from 'node:http';
import { checkEmailAuthDns, ValidationError } from './check-email-dns.mjs';

const server = createServer(async (req, res) => {
  const url = new URL(req.url ?? '/', 'http://localhost');

  if (req.method !== 'GET' || url.pathname !== '/v1/email-auth') {
    res.writeHead(404, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ error: 'not_found' }));
    return;
  }

  try {
    const result = await checkEmailAuthDns(url.searchParams.get('domain'), {
      selector: url.searchParams.get('selector') ?? undefined,
    });
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify(result));
  } catch (err) {
    const isInput = err instanceof ValidationError;
    res.writeHead(isInput ? 400 : 500, { 'content-type': 'application/json' });
    res.end(JSON.stringify({ error: isInput ? 'invalid_request' : 'internal_error', message: isInput ? err.message : undefined }));
  }
});

server.listen(3000);

This requires one small change to the module: add export to the ValidationError class declaration so the server can import it. Then start the server with node server.mjs and query it:

curl "http://localhost:3000/v1/email-auth?domain=example.com&selector=default"

To compare the same records outside your code, run these commands and check that the chunks match what your API returns:

dig +short TXT example.com
dig +short TXT _dmarc.example.com
dig +short TXT default._domainkey.example.com

Hardening a public endpoint

Every request can trigger several DNS queries, so a public deployment needs controls before launch:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • Validate before querying. The module rejects malformed domains and selectors. Keep it that way: never pass raw query strings to the resolver.
  • Limit query volume. Apply a per-client rate limit and a separate limit per queried domain, so one popular or hostile domain cannot consume the resolver budget. The privacy point above applies here: repeated checks of one domain send repeated queries to that owner’s infrastructure.
  • Cache with a fixed lifetime. The resolveTxt() result shape in the Node.js documentation carries no TTL, so choose a short fixed cache period, such as a few minutes, and label cached responses with their checkedAt time.
  • Bound selector discovery. Do not expose an endpoint that loops over a list of selectors per request. One selector per call keeps the cost predictable.
  • Tune the resolver. The timeout and tries values in the module are starting points. Measure your resolver’s behavior under load before choosing final values.

Add an authentication layer or an API gateway if the service is not meant to be open. The checker itself does not decide who may call it.

Where to go from here

Once the publication checker works, the next step is a separate evaluator for SPF that accepts a sending IP address, the HELO identity, and the MAIL FROM address. For DKIM, a verifier needs the full signed message and must canonicalize the headers and body exactly as the signature specifies. RFC 6376 at RFC 6376 defines those rules, and RFC 7208 at RFC 7208 defines SPF evaluation. Keep those components separate from the DNS checker so each response states exactly what was established.

The DNS module in this article is a sound foundation for the first two checks in the list above. Its results are accurate to what is published, and that is the boundary the API should state to its users.

Source references for the Node.js behavior used here are in the Node.js DNS documentation for v26.3.1. Check the same page for your own Node release before deploying, because resolver options and error codes can change between versions.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More from the Wire

  1. World desk4 min
    How to Spot an AI Voice Scam Before Sending MoneyDon’t rely on how a caller sounds. Pause, call back through a known number, and verify the emergency with another trusted person before sending money.
  2. Mountain View desk4 min
    Google’s SynthID Detector: How to Check AI-Generated Images, Video and AudioGoogle’s SynthID Detector looks for an embedded watermark in supported images, video and audio. Here is what its results do—and do not—show.
  3. Redmond desk20 min
    How to create a link to File or Folder in Windows 11Windows 11 gives you several ways to point to a file or folder without moving or duplicating it. You can create a desktop shortcut,…
Recommended PC Tool
Recommended PC Tool
Outdated Drivers Are Slowing You DownFree scan - exact matches
Windows Errors? Fix Them Before They SpreadFree repair scan

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.