01The symptom
You have a firewall policy whose destination is an FQDN address object — say
app.example.com pointing at a load-balanced service. Behaviour in the field:
- Connectivity is intermittent: some sessions to the service succeed, others are dropped, with no change to the config in between.
- A retry (new source port, fresh DNS lookup) often just works, which makes it feel like a flaky app rather than the firewall.
- Traffic logs show the failing sessions not matching the intended policy (they hit a later deny, or "no matching policy").
- The authoritative DNS serves the record with a very low or zero TTL and hands out its A records in a rotating (round-robin) order.
02How a FortiGate FQDN object actually works
An FQDN address object is not resolved per-packet. The FortiGate resolves the name itself, on its own schedule, using its system DNS servers, and caches the returned addresses. Those cached IPs are what the policy engine actually matches against.
config firewall address
edit "svc-app"
set type fqdn
set fqdn "app.example.com"
next
end
The refresh cadence is driven by the DNS record's TTL — but bounded on the
FortiGate side. Two global limits under config system dns cap it:
fqdn-min-refresh (default 60 s — the FortiGate never re-queries a
name faster than this, even if the DNS TTL is 0) and fqdn-max-refresh (default
3600 s — long TTLs are clamped down to this). Crucially, each FQDN object also has its own
cache-ttl, whose default of 0 means "follow the DNS record's TTL" — so a
zero DNS TTL is inherited straight through. A policy that references svc-app matches a
session only if the session's destination IP is currently one of the cached IPs. You
can see the live cache for every FQDN object:
diagnose firewall fqdn list
List all FQDN: app.example.com: ID( 9) REF(2) ADDR(203.0.113.11) ADDR(203.0.113.12) ADDR(203.0.113.13)
FQDN refresh = clamp( DNS TTL or object cache-ttl, fqdn-min-refresh, fqdn-max-refresh )
03Root cause: TTL 0 + round-robin
The failure needs two ingredients together, which is why it looks random:
- TTL 0 tells every resolver "do not cache this." With the object's
cache-ttlat its default of 0, the FortiGate inherits that zero TTL and re-queries the name as often as it is allowed to — i.e. at thefqdn-min-refreshfloor, roughly every 60 s by default. So the object's contents keep turning over instead of holding still. - Round-robin means the DNS server rotates its answer. Many load-balancing resolvers go further and return only a subset (sometimes a single A record) per query, different each time.
Now add the crucial detail: the client resolves the name independently — its own
resolver, its own round-robin draw — and opens the session to whichever IP it got, say
203.0.113.20. The FortiGate, meanwhile, did its own lookup a moment ago and
currently holds a different rotation, say {203.0.113.11, .12, .13}. The client's
destination IP is not in the FortiGate's set, so the FQDN policy does not match that session →
it is denied. With TTL 0 the two sides drift apart again and again.
Failure = dst IP(session) ∉ cache(FQDN) at match time · made frequent by (TTL = 0) × (round-robin / partial answers)
An honest nuance. A FortiGate stores the whole answer set it receives (up to a per-object cap), so if the DNS always returned the complete list and merely rotated the order, matching would usually still succeed. The trap bites when TTL 0 forces constant refresh — creating frequent brief windows where the cache is mid-update or holds a stale rotation — and/or the resolver hands out partial subsets, and/or the real pool is larger than a single answer (or the object) holds. Your case, TTL 0 with round-robin, is exactly the combination that keeps the FortiGate's set and the client's chosen IP out of sync. So the supposition is correct: the zero TTL is the root cause, and the round-robin is what makes the mismatch land.
04Prove it: the diagnostics
Don't guess — watch the two sides disagree. First, on the FortiGate, list the object's cache a few times a few seconds apart and note that the IPs (or their set) churn:
diagnose firewall fqdn list # run several times, a few seconds apart get firewall address svc-app # the object's currently-resolved addresses
app.example.com: ID( 9) REF(2) ADDR(203.0.113.11) ADDR(203.0.113.12) # ...seconds later... app.example.com: ID( 9) REF(2) ADDR(203.0.113.13) ADDR(203.0.113.11)
Inspect the FortiGate's DNS resolver cache to see the TTL it was handed (a 0 here is the
smoking gun):
diagnose test application dnsproxy 6 # dump the DNS proxy/resolver cache (incl. TTL)
From a client on the same path, resolve the name repeatedly and watch both the rotation and the TTL column:
for i in 1 2 3; do dig +noall +answer app.example.com; done
app.example.com. 0 IN A 203.0.113.20 app.example.com. 0 IN A 203.0.113.21 app.example.com. 0 IN A 203.0.113.22
Finally, tie a failing session to the mismatch: capture what the client connected to, then confirm that IP was not in the FortiGate's FQDN set at that moment.
diagnose sys session filter dst 203.0.113.20 diagnose sys session list # is the session there, and on which policy? or denied?
If the failing destination IPs are consistently ones that are absent from
diagnose firewall fqdn list at the time, you have proven the root cause.
05The fix: the FortiGate FQDN cache-ttl
Fix it on the FortiGate, not at the DNS server — override the inherited zero TTL with
the FQDN object's own cache-ttl. Its default of 0 means "follow the DNS
record's TTL," which is exactly how the 0 leaked in; set it to a fixed, non-zero value and the
FortiGate holds the resolved IP set for that many seconds regardless of what the DNS TTL says. Setting
this fqdn-ttl from 0 to a real value is the change that resolves the incident.
config firewall address
edit "svc-app"
set type fqdn
set fqdn "app.example.com"
set cache-ttl 86400 # Fortinet-recommended: hold the resolved IPs for 24h; 0 (default) = follow the DNS TTL
next
end
Keep the fix scoped to the affected object. The global
config system dns refresh settings (fqdn-min-refresh,
fqdn-max-refresh) set the default for every FQDN object and are best left at a
moderate "sweet spot" — you don't crank them site-wide to solve one object's problem. The per-object
cache-ttl above overrides that default for just the one name that suffers under
round-robin (or otherwise changing) resolution:
config system dns
set fqdn-min-refresh 60 # global default floor — leave at a sensible value (default 60)
set fqdn-max-refresh 3600 # global default ceiling — leave at a sensible value (default 3600)
end
Complementary options, in order of preference:
- Fix the source too, if you own the DNS. A zero TTL on a load-balanced record is
rarely intentional — raising the authoritative record's TTL (e.g. 300 s) helps every consumer,
not just the FortiGate. The
cache-ttlabove is what protects you when you don't control that DNS. - Point clients and the FortiGate at the same resolver so their round-robin draws
align. This shrinks the mismatch window but does not remove it — prefer the
cache-ttlfix. - For genuinely large / fast-rotating pools (big CDNs), an FQDN object is the wrong tool. Match the traffic with an Internet Service (ISDB) object or a broader destination instead of chasing DNS.
06Why raising the cache-ttl works
With cache-ttl set to T seconds, the FortiGate holds the resolved set for
T seconds and stops re-querying on the ~60 s fqdn-min-refresh floor. Within
that window every session's destination IP stays in the object, so the FQDN policy matches
deterministically — and, just as important, an established session is never
re-evaluated out of the object and torn down. The refresh churn that a 0 TTL created disappears;
round-robin no longer matters, because the FortiGate is holding a settled set rather than a snapshot of
one rotation.
Stable = (cache-ttl > 0) ⇒ set held for cache-ttl ⇒ dst IP(session) ∈ set ⇒ policy matches & session survives
07Best practices & prevention
- Never serve 0 for names used in FQDN objects. Give records a real TTL (minutes, not zero); TTL 0 defeats every DNS cache by design, including the FortiGate's.
- Keep the global refresh at a moderate "sweet spot." The site-wide
fqdn-min-refresh/fqdn-max-refreshgovern every FQDN object, so leave them at a sensible default — cranking them site-wide to solve one object is the wrong lever. - Pin the problem object with a per-object
cache-ttl. When one specific FQDN object suffers under round-robin — or just regular IP changes in resolution — set that object'scache-ttlto 86400 s (24 h) as best practice: it holds a stable set for that one name without touching anything else. The only trade-off is slower failover if that name's IPs genuinely change, so shorten it for names whose addresses really do rotate over time. - Align resolvers. Where practical, have clients and the FortiGate resolve through the same DNS so their views of a rotating name agree.
- Right tool for the target. FQDN objects suit stable, small answer sets. For huge or rapidly-rotating pools, use Internet Service / ISDB objects.
- Monitor the cache. Watch
diagnose firewall fqdn list; an object that is frequently empty or single-valued is an early warning of exactly this problem.
08Where to go next
Same appliance, different angle: drive FortiGate and FortiManager over their REST APIs in Insomnia as an API Remote Control, and see the DNS-adjacent fundamentals in IPv6 Basics. Back to all Troubleshooting notes.