AXFR transfers a complete DNS zone so another server or authorized client can obtain its data. A secondary can then answer authoritatively from its local copy. The protocol does not magically identify callers as legitimate authoritative servers: access control decides who may transfer. A zone is also a defined authority boundary, not every record in every delegated child zone or the organization’s entire DNS namespace.
When it breaks
Editing primary data without updating the SOA serial can leave a secondary serving different content with the same version number. Reloading the primary does not force every secondary to replace an apparently unchanged zone. DNS serial comparison uses 32-bit serial arithmetic, including wraparound rules; a date-looking number is only an operator convention, not an independent guarantee that every numeric change is newer.
An unrestricted transfer can reveal the zone’s full published contents to an arbitrary requesting client. Source restrictions and TSIG can control access, but their configuration semantics matter. In particular, listing an address and a key in an ordinary BIND allow-list is not automatically an AND requirement. Review the actual ACL behavior and test an unauthorized request from a relevant source, rather than assuming two listed conditions both apply.
A secondary’s ability to answer during primary failure is bounded by SOA expiry. Record TTL controls caching by downstream consumers; it is not the same as the secondary’s expiry timer for its authoritative copy. After unsuccessful refresh for the allowed period, the secondary must stop treating that copy as current authoritative data. Existing successful answers do not establish unlimited independence from the primary.
Check it quickly
Save this as check-zone-transfer.py and run python3 check-zone-transfer.py with Docker. Two BIND 9.18.49 processes run inside an isolated container. A randomly generated disposable TSIG secret authorizes transfers, and is not printed. The fixture uses deliberately short refresh and expiry values to expose state changes promptly; these values are test settings, not a suggested production policy:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,re,signal,subprocess,time
import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype
def run(*args,check=True):
result=subprocess.run(args,capture_output=True,text=True,timeout=15)
if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
return result
def query(name,kind='A',port=1053):
return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1)
def values(response,kind='A'):
wanted=dns.rdatatype.from_text(kind)
return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset]
def show(response):
def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records]
return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)}
def wait(predicate,timeout=15):
deadline=time.monotonic()+timeout
while time.monotonic()<deadline:
try:
if predicate():return
except (OSError,dns.exception.Timeout):pass
time.sleep(.05)
raise AssertionError('DNS fixture condition did not become true')
def stop(process):
if process.poll() is None:
process.terminate()
try:process.wait(timeout=5)
except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5)
@contextmanager
def lab(records,soa_ttl=8,minimum=4,extra=''):
with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack:
root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1
def save(body):
zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n')
run('named-checkzone','lab.test',str(zone))
save(records)
conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}};
recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none;
{extra} }};
controls {{}};
zone "lab.test" {{ type primary; file "{zone}"; }};
''')
run('named-checkconf',str(conf))
output=stack.enter_context(log.open('w'))
authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
stack.callback(stop,authority)
def ready():
assert authority.poll() is None,log.read_text()
response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial
wait(ready)
def update(body):
nonlocal serial
serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready)
def cache(port=1054,upstream=1053,size=150,options=()):
logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w'))
command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces',
'--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream),
'--cache-size='+str(size),'--log-queries','--log-facility=-',*options]
process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process)
def cache_ready():
assert process.poll() is None,logfile.read_text()
return query('lab.test.','SOA',port).rcode()==0
wait(cache_ready);return process,logfile
def queries(name,kind='A'):
return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines())
yield root,update,cache,queries
@contextmanager
def named_process(root,config):
root.mkdir(exist_ok=True);conf=root/'named.conf';log=root/'named.log';conf.write_text(config)
run('named-checkconf',str(conf))
with log.open('w') as output:
process=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT)
try:
def ready():
assert process.poll() is None,log.read_text()
return re.search(r'\brunning\s*$',log.read_text(),re.M) is not None
wait(ready);yield process,log
finally:stop(process)
import base64,secrets
import dns.tsig,dns.tsigkeyring
with TemporaryDirectory(prefix='xbcatm-xfr-') as directory:
root=Path(directory);primary=root/'primary';secondary=root/'secondary';primary.mkdir();secondary.mkdir()
secret=base64.b64encode(secrets.token_bytes(32)).decode();keyring=dns.tsigkeyring.from_text({'xfr.lab.test.':secret})
key_config=f'key "xfr.lab.test." {{ algorithm hmac-sha256; secret "{secret}"; }};'
zone=primary/'zone'
def save(serial,address):
zone.write_text(f'$ORIGIN lab.test.\n$TTL 2\n@ IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 1 1 6 1)\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\nservice IN A {address}\n')
run('named-checkzone','lab.test',str(zone))
def options(path,port):return f'''options {{ directory "{path}"; listen-on port {port} {{127.0.0.1;}}; listen-on-v6 {{none;}};
recursion no; dnssec-validation no; max-cache-size 8m; session-keyfile none; pid-file "{path}/named.pid"; lock-file "{path}/named.lock";
min-refresh-time 1; max-refresh-time 2; min-retry-time 1; max-retry-time 2; }}; controls {{}};'''
save(10,'127.0.0.2')
primary_config=key_config+options(primary,1053)+f'''zone "lab.test" {{ type primary; file "{zone}";
allow-transfer {{key "xfr.lab.test.";}}; notify explicit; also-notify {{127.0.0.1 port 1054 key "xfr.lab.test.";}}; }};'''
secondary_config=key_config+options(secondary,1054)+f'''zone "lab.test" {{ type secondary; file "{secondary}/copy";
primaries {{127.0.0.1 port 1053 key "xfr.lab.test.";}}; allow-transfer {{none;}}; }};'''
with named_process(primary,primary_config) as (master,master_log),named_process(secondary,secondary_config):
wait(lambda: values(query('service.lab.test.',port=1054))==['127.0.0.2'])
def transfer(**kwargs):return [rr for message in dns.query.xfr('127.0.0.1','lab.test.',port=1053,timeout=2,lifetime=4,relativize=False,**kwargs) for rr in message.answer]
copied=transfer(keyring=keyring,keyname='xfr.lab.test.')
assert copied[0].rdtype==copied[-1].rdtype==dns.rdatatype.SOA
assert copied[0][0].serial==copied[-1][0].serial==10
assert any(str(rr.name)=='service.lab.test.' and any(str(item)=='127.0.0.2' for item in rr) for rr in copied)
rejected={}
for label,kwargs in [('unsigned',{}),('wrong_secret',{'keyring':dns.tsigkeyring.from_text({'xfr.lab.test.':base64.b64encode(secrets.token_bytes(32)).decode()}),'keyname':'xfr.lab.test.'})]:
try:transfer(**kwargs);raise AssertionError('unauthorized zone transfer succeeded')
except (dns.query.TransferError,dns.tsig.PeerBadSignature) as error:rejected[label]=type(error).__name__
assert values(query('service.lab.test.'))==['127.0.0.2']
save(10,'127.0.0.3');master.send_signal(signal.SIGHUP);wait(lambda: values(query('service.lab.test.'))==['127.0.0.3'])
time.sleep(2.2);unchanged=query('service.lab.test.',port=1054);assert values(unchanged)==['127.0.0.2']
save(11,'127.0.0.3');master.send_signal(signal.SIGHUP)
wait(lambda: query('lab.test.','SOA').answer[0][0].serial==11)
wait(lambda: values(query('service.lab.test.',port=1054))==['127.0.0.3'])
secondary_soa=query('lab.test.','SOA',1054);assert secondary_soa.answer[0][0].serial==11
requested_ixfr=transfer(keyring=keyring,keyname='xfr.lab.test.',rdtype='IXFR',serial=10)
assert requested_ixfr[0][0].serial==11 and requested_ixfr[1].rdtype!=dns.rdatatype.SOA
assert requested_ixfr[-1].rdtype==dns.rdatatype.SOA and requested_ixfr[-1][0].serial==11
stop(master)
while_valid=query('service.lab.test.',port=1054);assert values(while_valid)==['127.0.0.3']
expired=[]
def no_longer_served():
answer=query('service.lab.test.',port=1054);expired.append(answer);return answer.rcode()==dns.rcode.SERVFAIL
wait(no_longer_served,timeout=12)
print(json.dumps({'AXFR_SOA_serials':[copied[0][0].serial,copied[-1][0].serial],'AXFR_record_sets':len(copied),
'unauthorized_transfer_errors':rejected,'same_serial_secondary_address':values(unchanged),'updated_secondary_serial':11,
'IXFR_without_history_returned_AXFR':True,'primary_stopped_secondary_initially_answers':values(while_valid),
'secondary_after_SOA_expire':dns.rcode.to_text(expired[-1].rcode())},sort_keys=True))
"""
name='xbcatm-dns-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'Dockerfile').write_text(recipe)
built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr
result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name,
'/usr/bin/python3','-',input=program)
assert result.returncode==0,result.stderr
print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
docker('rm','-f',name);docker('image','rm',name)
The primary starts at serial 10 with service.lab.test pointing to 127.0.0.2. A real secondary transfers the zone and answers that address. An ordinary Python client using the authorized TSIG key also completes AXFR. Its five RRset entries include opening and closing SOA records with serial 10 and the expected service data, demonstrating both transfer completion and access by an authorized non-server client.
An unsigned request and a request signed with a different secret fail, even though the requests originate inside the same loopback environment. Ordinary permitted address queries still succeed. The tested rule requires the configured key for transfer; it does not make every DNS query require TSIG. Authentication of the transfer operation is separate from publishing ordinary authoritative answers.
The primary then reloads a changed service address, 127.0.0.3, while retaining serial 10. After the short refresh window, the secondary still answers 127.0.0.2. Raising the serial to 11 and reloading lets the secondary acquire the changed address. The example therefore verifies the divergence and its repair, rather than merely checking that both configuration files mention the same primary.
An IXFR request for changes since serial 10 receives a full AXFR-style response because this primary retains no incremental history for the update. The script identifies the response’s SOA framing. Finally, it stops the primary. The secondary initially continues answering 127.0.0.3, then returns SERVFAIL after its short SOA expiry interval. No host clock or shared network setting is changed to produce that transition.
Validate replication, authorization, and confidentiality separately
NOTIFY can prompt an earlier check, while periodic refresh provides another opportunity to detect a newer serial. Neither makes replication instantaneous. Monitor each authoritative server’s serial and actual answer data, and investigate transfer failures before expiry removes service. When repairing a serial mistake, ensure the secondary receives the intended complete state rather than assuming that any subsequent incremental update will repair arbitrary divergence.
IXFR can save work when a suitable change history exists, but the request type does not guarantee an incremental response. AXFR can span multiple DNS messages over TCP, so complete-transfer verification must respect its protocol boundaries. A partial stream or a single matching record is insufficient evidence that the entire zone arrived and can be served correctly.
TSIG authenticates messages using shared key material and protects integrity; it does not encrypt the zone contents. Protect the key and apply transport confidentiality where the deployment requires it. Keep transfer authorization, successful replication, ordinary query availability, and replica expiry as distinct checks. Together they establish the practical boundary far more clearly than an assumption that transfers only happen between trusted servers.
Sources
- RFC 5936 AXFR, full-zone transfer framing and completion, checked 2026-09-12.
- RFC 1995 IXFR, incremental requests and full-transfer fallback, checked 2026-09-12.
- RFC 1982 serial number arithmetic, 32-bit DNS serial comparisons and wraparound, checked 2026-09-12.
- RFC 8945 TSIG, shared-key message authentication and integrity, checked 2026-09-12.
- BIND 9.18.49 reference, transfer ACLs, notification, and secondary refresh controls, checked 2026-09-12.