API

DNS leak test API

Checks where a machine’s DNS queries actually go, by observing which resolver reaches our authoritative nameserver. A VPN is the common case but not a requirement. Queries escape a configured resolver just as easily without one. Free, no key, no account. Rate limits are generous and documented below.

Why this cannot be answered locally

Reading /etc/resolv.conf or running dig tells you which resolver a machine is *configured* to use. It cannot tell you which resolver actually answered, because that is only observable from the authoritative side. This service is that side.

You are testing the network this process runs on. For a tool running on the user’s own machine that is exactly the right answer. For anything hosted, such as a cloud agent or a CI runner, the result describes that datacenter, not you. Decide which one you meant before reporting it.

Usage

  1. GET /api/new returns a testId and a list of single-use hostnames.
  2. Resolve every hostname with the system resolver. The answers are irrelevant and are expected to fail TLS if fetched over HTTPS. Only the lookup matters.
  3. GET /api/result/<testId> returns the resolvers observed, each with location and network operator, plus a verdict. Results are kept for ten minutes.

Send Accept: text/plain for terminal-readable output, or Accept: application/json for JSON. curl and wget get text by default.

Resolve both name families

Half the hostnames end -aNN and answer A records. Half end -qNN and answer AAAA while deliberately withholding A. Resolving only the `-a` half is an incomplete test. It says nothing about the IPv6 path, which is where a tunnel most often leaks. Resolve the whole list you were given.

Resolving both halves enumerates a resolver pool more thoroughly and exercises the AAAA path in your stub resolver. It does not, on its own, separate IPv4 from IPv6: which resolver answers is chosen by the stub resolver, not by the query type.

Limits

Token buckets per calling address. Exceeding one returns 429 with a Retry-After header; back off and retry rather than treating it as a failure.

  • Starting a test (/api/new, /api/session): 60 immediately, then one every 2 seconds.
  • Reading a result (/api/result/<id>): 600 immediately, refilling ten times a second. Polling is the expected flow, so this is deliberately loose.
  • GET /api/health reports whether geolocation enrichment is healthy. No addresses, no traffic figures.

There is no bot detection and no user-agent filtering, deliberately: agents are a supported audience. Please do not poll a finished test in a tight loop, because a result only changes while queries are still arriving.

Examples

shell

ID=$(curl -s -H 'Accept: application/json' https://dnsleak.dev/api/new \
  | grep -o '"testId":"[^"]*"' | cut -d'"' -f4)

for n in 01 02 03 04 05 06; do
  dig +short      "$ID-a$n.t.dnsleak.dev" >/dev/null   # A records
  dig +short AAAA "$ID-q$n.t.dnsleak.dev" >/dev/null   # AAAA only
done

sleep 1
curl -s "https://dnsleak.dev/api/result/$ID"

node

import { promises as dns } from 'node:dns';

const base = 'https://dnsleak.dev';
const { testId, hostnames } = await (
  await fetch(`${base}/api/new`, { headers: { accept: 'application/json' } })
).json();

// Both families. A -q name has no A record, so ask it for AAAA.
await Promise.all(
  hostnames.map((h) =>
    dns.resolve(h, h.includes('-q') ? 'AAAA' : 'A').catch(() => {})
  )
);

const report = await (
  await fetch(`${base}/api/result/${testId}`, { headers: { accept: 'application/json' } })
).json();

console.log(report.verdict.status, report.verdict.detail);

python

import socket, time, urllib.request, json

base = "https://dnsleak.dev"

def get(path):
    req = urllib.request.Request(base + path, headers={"Accept": "application/json"})
    return json.load(urllib.request.urlopen(req))

new = get("/api/new")

for host in new["hostnames"]:
    family = socket.AF_INET6 if "-q" in host else socket.AF_INET
    try:
        socket.getaddrinfo(host, None, family)
    except socket.gaierror:
        pass  # the lookup is the payload; the answer is not

time.sleep(1)
report = get("/api/result/" + new["testId"])
print(report["verdict"]["status"], report["verdict"]["detail"])

There is deliberately no curl … | sh one-liner. Piping a remote script into a shell is exactly the habit a privacy tool should not be teaching.

Interpreting the result

verdict.status
no_leak, split or inconclusive. Read this, not `leaking`, and handle an unrecognised value rather than assuming the set is closed. leak exists in the type and nothing currently returns it: the only evidence we accept for one is the split case. split means most of your lookups went to one resolver and a minority went somewhere else. Something bypassed something, which is what an intermittent leak looks like. inconclusive means no determination was made, and covers two cases: nothing reached our nameserver so the test never ran, or a resolver answered from another country with nothing to corroborate a leak. We cannot see which resolver you configured, so we name who answered rather than grade it, because a different country is not by itself a leak. Treating either case as a pass would be wrong.
verdict.leaking
Kept for compatibility, and true only for split. It is false for inconclusive, which is not a pass. That is the whole reason status is the field to read. headline and detail say the same thing as prose.
client
The caller as we see them: ip, city, country, countryCode, lat, lon, asn, org, hostname. Everything else is judged against this.
lookups
How many of the test hostnames reached our nameserver. Each hostname counts once, however many addresses asked for it. If it is lower than the number you resolved, some lookups have not arrived yet or were stopped on the way, so read the result again before trusting the verdict.
groups[]
Resolvers collapsed by operator. One resolver commonly answers from many egress addresses, and Cloudflare routinely uses a dozen or more in a single test, so groups.length is what "how many resolvers" means. label is what to call it, members are its addresses, status is its worst member. Observers are not in here. See observerGroups.
observerGroups[]
Addresses that resolved a test hostname somebody else had already resolved. Every name is single-use and unpublished, so the resolver doing your lookups is always the first to ask for one; anything repeating a name learned it second-hand, from something the browser handed the URL to. Same shape as groups[]. These are not your DNS and never affect the verdict. Not shown in the web UI, because a second list of resolver-shaped things under a "no leak" headline only reads as a hedge. Kept here because it is useful when something looks wrong.
resolvers[].role
resolver if it answered for you, observer if it only ever repeated lookups. Decided per operator, not per address: pool members retry each other, so a group counts as answering if any of its addresses introduced a name.
resolvers[].original / .echoed
Names this address was first to ask for, and names it asked for only after another address had. echoLagMs is how far behind the first ask its earliest repeat came. These three are the evidence behind role.
resolvers[].status
matched (same country and network, which is also what an ISP hijacking port 53 looks like, so it states who answered and grades nothing), same-country (same country, different network), operator (a known public provider), foreign (a different country to you, reported and not graded), minority (handled a small share of your lookups alongside a dominant resolver; this is what drives a split verdict), observer (not your resolver; see role), or unknown when we could not place the caller. minority wins over foreign when both apply: a resolver abroad that handled a small share reports minority, because the share is the finding and the country is a detail of it. So do not read the absence of foreign as "nothing answered from abroad". Check country on the group. This ordering is deliberate: the reverse shipped once, and it graded a minority escaping abroad as milder than the same minority staying domestic.
resolvers[].operator
The named provider, identified from reverse DNS, then known address ranges, then ASN. Not a leak if the user chose it; encrypted DNS is a deliberate configuration.
resolvers[].anycast
True for named operators. Their addresses are anycast, so city and country on those entries are a database guess about a range serving many locations, not where the query went. Do not build a verdict on them; we do not.
resolvers[].sawA / .sawQ
Which name family this resolver was asked about. Useful for seeing how a pool spread across the run. Do not read an IPv6 leak into a one-sided split. A rotating pool produces them by chance, and sawQ is set by any query for a -q name including an A query, so the split does not track the transport the caller used.
resolvers[].hostname
Reverse DNS, when it exists. Often the only conclusive evidence of who a resolver is: a NextDNS node on leased address space reports its landlord’s ASN but a PTR of dns.nextdns.io.
resolvers[].ecs
A network prefix the resolver volunteered via EDNS Client Subnet. A privacy finding in its own right, not a leak. ecsIsClient says whether that prefix actually contains the caller. False means the resolver sent a coarser nearby block.
resolvers[].dnssecOk
The resolver set the DNSSEC OK bit, so it is DNSSEC-aware. This does not prove it validates signatures.
resolvers[].transport
udp or tcp. count is how many queries that address answered, firstSeen when it first reached us.
truncated
The nameserver stopped recording new addresses at its per-test cap. The operators are complete; the address lists may not be.

Notes

Changing DNS provider does not fix a leak; it changes who receives the leaked queries. The fix belongs wherever the query is being diverted: the VPN, the OS, the router, or a device that ignores both.

The page itself loads nothing from anyone else: no trackers, no analytics, no third-party scripts, fonts or STUN servers. Server-side, addresses are resolved against local offline IP databases and enriched with reverse DNS. A hosted geolocation lookup is spent only where the local answer is unusable, such as an anycast resolver or an unplaced datacenter range, because registry-derived data puts those in the wrong country often enough to produce false verdicts. Ordinary visitor addresses never leave the machine. Database credits are listed on /privacy.

Test results are held in memory for ten minutes and then discarded. What is written to disk is a daily tally of how many tests ran, how many completed and how many found a leak. Counts alone, with no addresses, user agents or paths, and nothing that could be tied back to a visit. Requests refused for going over a rate limit also get one line each, with the time and which part of the site but no address, kept 14 days.

Pages