After publishing my previous post on a simple FGCP HA BGP deployment and how to reduce failover times with only a cluster and a single BGP peer, I got a comment on how this looks in a multi-homed environment, because I did not mention this scenario at all, and the way Graceful Restart (GR) works creates problems, as the comment explains. This was a totally valid comment, and after getting my hands on a fourth FortiGate and hours of testing, I can share my findings with you, dear reader.
Disclaimer
I truly did try to be diligent in my testing, but with all the configurations, options, and behaviours, something might have slipped by me. I hope that all the numbers I provide are correct, and I am truly sorry if I made a mistake anywhere.
If you end up using anything I write here, I urge you to test, test, and test. My lab is not your production environment, and you are probably not working with only FortiGates. Not every vendor implements BGP and its various mechanisms the same way, so the validation will ultimately be up to you.
The 70G cluster, using AS 65001, is peering with the 60F (ISP1), using AS 65003, and the 40F (ISP2), using AS 65004. Direct links, links connected to a switch or loopbacks are used (this depends on the cases being examined).
The 70G cluster and the ISP FortiGates have a client behind them. The ISP FortiGates serve as the gateway for the client using VRRP, but this isn’t mentioned in the topologies, won’t be a thing in the real world and is purely there to make it easier for me in my lab environment.
The 70G cluster is announcing 192.168.1.0/24, and the ISP FortiGate are each announcing a default route.
What am I trying to achieve?
The two main points I want to achieve are:
An HA failover should not lead to any or minimal traffic loss
HA failovers are triggered using diagnose sys ha reset-uptime
An ISP failover should not lead to any or minimal traffic loss
ISP failovers are triggered by shutting down the physical interface
I also have a few points that are important for this article:
I recommend reading my previous post first, because lots of things, like route-ttl, are important here too.
I always take the worst result I have experienced in my testing.
If one test resulted in 1 lost ping and another in 3 lost pings, I will only mention the test with 3 lost pings.
I test both with and without SNAT.
snat-route-change is enabled even if it doesn’t matter for my tests; it might matter to you.
All ping tests are performed by using a Windows ping with the -t and –w 100 options, so continuous pings with a timeout of 100 milliseconds. This is important, because when you read “3 pings lost with SNAT”, you have to keep in mind that these aren’t standard pings with a 4-second timeout.
Using a low timeout value is necessary because once we get to BFD, sub-second failovers are the name of the game.
I am using a scan-time of 5, an advertisement-interval of 3, and a connect-timer of 1. This is done both because it helps me with my testing and because we want to put our best foot forward.
The advertisement-interval can be taken down to 1, but I simply opted not to do that, because if this becomes a problem, the configuration is bad.
I am splitting this up into cases, where each case represents a specific configuration; the results and, if necessary, explanations.
I am keeping the BGP keepalive and holdtime-timer at the default values of 60 and 180, respectively. I will mention if they can make a difference.
BFD timers are left at the defaults.
Now, let’s get to testing.
Case 1: GR enabled, BGP on interface, no BFD, direct links
This is similar to the topology of my previous post except that a second ISP joined. Graceful Restart is enabled, BFD is not configured, and the cluster has direct connections to both ISPs.
Results:
HA failover: No pings lost
ISP failover: 4 pings lost with and without SNAT
This is a special case because direct links to two ISPs is probably not something you will encounter in the real world; however, BGP’s default behaviour of tearing down a peer if the interface is down helps here.
We can observe this behaviour by turning on BGP debugs.
diagnose debug console timestamp enable
diagnose ip router bgp all enable
diagnose ip router bgp level info
diagnose debug enable
We can clearly see the Link Down message type, and that signals that the attached BGP peer should be torn down, and at that point traffic only has one exit, the other ISP.
For completeness sake, here is an example of a Link Up message:
And the relevant configuration of the cluster and the ISPs:
Case 1 configuration for cluster and ISPs
!!!HA CLUSTER!!!
config system global
set snat-route-change enable
end
config system ha
set route-ttl 30
end
config router bgp
set as 65001
set router-id 192.0.2.1
set ebgp-multipath enable
set additional-path enable
set scan-time 5
config neighbor
edit 172.31.255.10
set advertisement-interval 3
set capability-graceful-restart enable
set soft-reconfiguration enable
set interface "port3"
set remote-as 65003
set connect-timer 1
set update-source "port3"
next
edit 172.31.254.10
set advertisement-interval 3
set capability-graceful-restart enable
set ebgp-enforce-multihop enable
set soft-reconfiguration enable
set interface "port4"
set remote-as 65004
set connect-timer 1
set update-source "port4"
next
end
config network
edit 1
set prefix 192.168.1.0 255.255.255.0
next
end
end
!!!ISP1!!!
config router bgp
set as 65003
set router-id 192.0.2.3
set scan-time 5
config neighbor
edit "192.0.2.1"
set advertisement-interval 3
set capability-graceful-restart enable
set capability-default-originate enable
set soft-reconfiguration enable
set interface "SW_BGP"
set remote-as 65001
set connect-timer 1
set update-source "SW_BGP"
next
end
end
!!!ISP2!!!
config router bgp
set as 65004
set router-id 192.0.2.4
set scan-time 5
config neighbor
edit "192.0.2.1"
set advertisement-interval 3
set capability-graceful-restart enable
set capability-default-originate enable
set soft-reconfiguration enable
set interface "SW_BGP"
set remote-as 65001
set connect-timer 1
set update-source "SW_BGP"
next
end
end
Case 2: GR enabled, BGP on interface, no BFD, switched links
The only thing that changed is that now the FortiGates are all connected to a switch, and each ISP gets a VLAN so the peers can find each other. This is probably the most common topology, because getting two links from an ISP can be a challenge in itself.
Results:
HA failover: No pings lost
ISP failover: Complete traffic loss, because of GR
This is expected, because the Link Down message is never generated, since the link to the switch stays up on the cluster’s side and traffic is blackholed. At this point, you are at the mercy of the BGP hold timer.
The configuration is the same as case 1.
Case 3: GR enabled, BGP on interface, BFD enabled in BGP, switched links
The topology stays the same as with case 2, but BFD capabilities are now enabled in the settings and for BGP.
Results:
HA Failover:
1 ping lost with SNAT
6 pings lost without SNAT
ISP failover:
3 pings lost with and without SNAT
The HA failover scenario without SNAT is surprising, but that’s how it is. I even tested it again while writing this sentence.
The loss of pings on an HA failover is attributed to the fact that BFD is not synchronized in HA and the BFD session needs to re-establish on the new primary, which takes a bit over 5 seconds. Once the BFD session is up, forwarding works for non-SNAT sessions.
After BFD, the BGP session needs to re-establish, so the synchronized HA routes, thanks to route-ttl, are being used in the meantime.
Enabling BFD debugs and a debug flow shows the behaviour for a non-SNAT scenario:
diagnose debug console timestamp enable
diagnose debug application bfdd -1
diagnose ip router bfd all enable
diagnose debug enable
HA failover with SNAT
2026-08-23 15:13:55 bfd_ha_event: Got HA MESSAGE, msg->nvdoms=1
2026-08-23 15:13:55 bfd_ha_event: HA state vfid=0 old=78336 new=8704
2026-08-23 15:13:55 bfd_ha_event: vd=root is master
2026-08-23 15:13:55 bfd_cmdb_vd_callback: VD=root no bfd neighbours or multihops.
2026-08-23 15:13:56 id=65308 trace_id=512 func=print_pkt_detail line=6393 msg="vd-root:0 received a packet(proto=1, 192.168.1.231:1->10.10.100.30:2048) tun_id=0.0.0.0 from wan1. type=8, code=0, id=1, seq=1002."
2026-08-23 15:13:56 id=65308 trace_id=512 func=resolve_ip_tuple_fast line=6501 msg="Find an existing session, id-0000077f, original direction"
2026-08-23 15:13:56 id=65308 trace_id=512 func=__vf_ip_route_input_rcu line=2116 msg="find a route: flag=00000000 gw-172.31.255.10 via port3"
2026-08-23 15:13:56 id=65308 trace_id=512 func=__iprope_tree_check line=529 msg="gnum-100004, use int hash, slot=31, len=4"
2026-08-23 15:13:56 id=65308 trace_id=512 func=get_new_addr line=1331 msg="find SNAT: IP-172.31.255.1(from IPPOOL), port-5132"
2026-08-23 15:13:56 id=65308 trace_id=512 func=__ip_session_run_tuple line=3682 msg="SNAT 192.168.1.231->172.31.255.1:5118"
2026-08-23 15:13:57 id=65308 trace_id=513 func=print_pkt_detail line=6393 msg="vd-root:0 received a packet(proto=1, 192.168.1.231:1->10.10.100.30:2048) tun_id=0.0.0.0 from wan1. type=8, code=0, id=1, seq=1003."
2026-08-23 15:13:57 id=65308 trace_id=513 func=resolve_ip_tuple_fast line=6501 msg="Find an existing session, id-0000077f, original direction"
2026-08-23 15:13:57 id=65308 trace_id=513 func=npu_handle_session44 line=1460 msg="Trying to offloading session from wan1 to port3, skb.npu_flag=00000400 ses.state=00010204 ses.npu_state=0x00000000"
2026-08-23 15:13:57 id=65308 trace_id=513 func=ip_session_install_npu_session line=445 msg="npu session installation succeeded"
2026-08-23 15:13:57 id=65308 trace_id=513 func=fw_forward_dirty_handler line=445 msg="state=00010204, state2=00000401, npu_state=00000400"
2026-08-23 15:13:57 id=65308 trace_id=513 func=__ip_session_run_tuple line=3682 msg="SNAT 192.168.1.231->172.31.255.1:5118"
2026-08-23 15:13:58 id=65308 trace_id=514 func=print_pkt_detail line=6393 msg="vd-root:0 received a packet(proto=1, 192.168.1.231:1->10.10.100.30:2048) tun_id=0.0.0.0 from wan1. type=8, code=0, id=1, seq=1004."
2026-08-23 15:13:58 id=65308 trace_id=514 func=resolve_ip_tuple_fast line=6501 msg="Find an existing session, id-0000077f, original direction"
2026-08-23 15:13:58 id=65308 trace_id=514 func=__iprope_tree_check line=529 msg="gnum-100004, use int hash, slot=31, len=4"
2026-08-23 15:13:58 id=65308 trace_id=514 func=get_new_addr line=1331 msg="find SNAT: IP-172.31.255.1(from IPPOOL), port-5133"
2026-08-23 15:13:58 id=65308 trace_id=514 func=__ip_session_run_tuple line=3682 msg="SNAT 192.168.1.231->172.31.255.1:5118"
2026-08-23 15:13:59 id=65308 trace_id=515 func=print_pkt_detail line=6393 msg="vd-root:0 received a packet(proto=1, 192.168.1.231:1->10.10.100.30:2048) tun_id=0.0.0.0 from wan1. type=8, code=0, id=1, seq=1005."
2026-08-23 15:13:59 id=65308 trace_id=515 func=init_ip_session_common line=6607 msg="allocate a new session-00000588"
2026-08-23 15:13:59 id=65308 trace_id=515 func=__vf_ip_route_input_rcu line=2116 msg="find a route: flag=00000000 gw-172.31.255.10 via port3"
2026-08-23 15:13:59 id=65308 trace_id=515 func=__iprope_tree_check line=529 msg="gnum-100004, use int hash, slot=31, len=4"
2026-08-23 15:13:59 id=65308 trace_id=515 func=get_new_addr line=1331 msg="find SNAT: IP-172.31.255.1(from IPPOOL), port-5118"
2026-08-23 15:13:59 id=65308 trace_id=515 func=fw_forward_handler line=1014 msg="Allowed by Policy-1: SNAT"
2026-08-23 15:13:59 id=65308 trace_id=515 func=ip_session_confirm_final line=3357 msg="npu_state=0x0, hook=4"
2026-08-23 15:13:59 id=65308 trace_id=515 func=__ip_session_run_tuple line=3682 msg="SNAT 192.168.1.231->172.31.255.1:5118"
2026-08-23 15:14:00 id=65308 trace_id=516 func=print_pkt_detail line=6393 msg="vd-root:0 received a packet(proto=1, 192.168.1.231:1->10.10.100.30:2048) tun_id=0.0.0.0 from wan1. type=8, code=0, id=1, seq=1006."
2026-08-23 15:14:00 id=65308 trace_id=516 func=resolve_ip_tuple_fast line=6501 msg="Find an existing session, id-00000588, original direction"
2026-08-23 15:14:00 id=65308 trace_id=516 func=npu_handle_session44 line=1460 msg="Trying to offloading session from wan1 to port3, skb.npu_flag=00000400 ses.state=04010204 ses.npu_state=0x00000000"
2026-08-23 15:14:00 id=65308 trace_id=516 func=ip_session_install_npu_session line=445 msg="npu session installation succeeded"
2026-08-23 15:14:00 id=65308 trace_id=516 func=fw_forward_dirty_handler line=445 msg="state=04010204, state2=00000001, npu_state=00000400"
2026-08-23 15:14:00 id=65308 trace_id=516 func=__ip_session_run_tuple line=3682 msg="SNAT 192.168.1.231->172.31.255.1:5118"
2026-08-23 15:14:01 bfd_nl_event: processing nl event...
2026-08-23 15:14:01 bfd_nl_event: processing nl event type=1 pid=1628
2026-08-23 15:14:01 bfd_session_send_packet_now: Session[172.31.254.1->172.31.254.10,49162,port4,B]: bfd_session_send_packet_now
2026-08-23 15:14:01 bfd_nl_process_register: reg 172.31.254.1->172.31.254.10 ifindex=8
2026-08-23 15:14:01 bfd_session_change_state: Session[172.31.254.1->172.31.254.10,49162,port4,B]: state DOWN -> INIT local_diag=0x00
2026-08-23 15:14:01 bfd_session_send_packet_now: Session[172.31.254.1->172.31.254.10,49162,port4,B]: bfd_session_send_packet_now
2026-08-23 15:14:01 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49162,port4,B]: received BFD packet
2026-08-23 15:14:01 bfd_session_change_state: Session[172.31.254.1->172.31.254.10,49162,port4,B]: state INIT -> UP local_diag=0x00
2026-08-23 15:14:01 bfd_session_send_packet_now: Session[172.31.254.1->172.31.254.10,49162,port4,B]: bfd_session_send_packet_now
2026-08-23 15:14:01 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49162,port4,B]: received BFD packet
2026-08-23 15:14:01 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49162,port4,B]: received BFD packet
2026-08-23 15:14:01 bfd_write:Session[172.31.254.1->172.31.254.10,49162,port4,B]: Sending BFD packet
2026-08-23 15:14:01 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49162,port4,B]: received BFD packet
2026-08-23 15:14:01 bfd_write:Session[172.31.254.1->172.31.254.10,49162,port4,B]: Sending BFD packet
2026-08-23 15:14:01 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49162,port4,B]: received BFD packet
2026-08-23 15:14:01 bfd_write:Session[172.31.254.1->172.31.254.10,49162,port4,B]: Sending BFD packet
2026-08-23 15:14:01 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49162,port4,B]: received BFD packet
2026-08-23 15:14:02 bfd_write:Session[172.31.254.1->172.31.254.10,49162,port4,B]: Sending BFD packet
2026-08-23 15:14:02 bfd_nl_event: processing nl event...
2026-08-23 15:14:02 bfd_nl_event: processing nl event type=1 pid=1628
2026-08-23 15:14:02 bfd_session_send_packet_now: Session[172.31.255.1->172.31.255.10,49163,port3,C]: bfd_session_send_packet_now
2026-08-23 15:14:02 bfd_nl_process_register: reg 172.31.255.1->172.31.255.10 ifindex=7
2026-08-23 15:14:02 bfd_session_change_state: Session[172.31.255.1->172.31.255.10,49163,port3,C]: state DOWN -> INIT local_diag=0x00
2026-08-23 15:14:02 bfd_session_send_packet_now: Session[172.31.255.1->172.31.255.10,49163,port3,C]: bfd_session_send_packet_now
2026-08-23 15:14:02 bfd_read_ipv4: Session[172.31.255.1->172.31.255.10,49163,port3,C]: received BFD packet
2026-08-23 15:14:02 bfd_session_change_state: Session[172.31.255.1->172.31.255.10,49163,port3,C]: state INIT -> UP local_diag=0x00
2026-08-23 15:14:02 bfd_session_send_packet_now: Session[172.31.255.1->172.31.255.10,49163,port3,C]: bfd_session_send_packet_now
In the debugs, we can see that SNAT, policy lookup and forwarding happen before BFD is even established on the new primary.
Looking at the same case, an HA failover, but without SNAT, it doesn’t look much different, except that real forwarding only happens after BFD has been established, which you can’t even see in the debugs.
HA failover without SNAT
2026-08-23 15:22:00 bfd_ha_event: Got HA MESSAGE, msg->nvdoms=1
2026-08-23 15:22:00 bfd_ha_event: HA state vfid=0 old=78336 new=8704
2026-08-23 15:22:00 bfd_ha_event: vd=root is master
2026-08-23 15:22:00 bfd_cmdb_vd_callback: VD=root no bfd neighbours or multihops.
2026-08-23 15:22:00 id=65308 trace_id=527 func=print_pkt_detail line=6393 msg="vd-root:0 received a packet(proto=1, 192.168.1.231:1->10.10.100.30:2048) tun_id=0.0.0.0 from wan1. type=8, code=0, id=1, seq=1419."
2026-08-23 15:22:00 id=65308 trace_id=527 func=resolve_ip_tuple_fast line=6501 msg="Find an existing session, id-00000914, original direction"
2026-08-23 15:22:00 id=65308 trace_id=527 func=__vf_ip_route_input_rcu line=2116 msg="find a route: flag=00000000 gw-172.31.254.10 via port4"
2026-08-23 15:22:00 id=65308 trace_id=527 func=__iprope_tree_check line=529 msg="gnum-100004, use int hash, slot=32, len=4"
2026-08-23 15:22:01 id=65308 trace_id=528 func=print_pkt_detail line=6393 msg="vd-root:0 received a packet(proto=1, 192.168.1.231:1->10.10.100.30:2048) tun_id=0.0.0.0 from wan1. type=8, code=0, id=1, seq=1420."
2026-08-23 15:22:01 id=65308 trace_id=528 func=resolve_ip_tuple_fast line=6501 msg="Find an existing session, id-00000914, original direction"
2026-08-23 15:22:01 id=65308 trace_id=528 func=__vf_ip_route_input_rcu line=2116 msg="find a route: flag=00000000 gw-172.31.254.10 via port4"
2026-08-23 15:22:01 id=65308 trace_id=528 func=__iprope_tree_check line=529 msg="gnum-100004, use int hash, slot=32, len=4"
2026-08-23 15:22:03 id=65308 trace_id=529 func=print_pkt_detail line=6393 msg="vd-root:0 received a packet(proto=1, 192.168.1.231:1->10.10.100.30:2048) tun_id=0.0.0.0 from wan1. type=8, code=0, id=1, seq=1421."
2026-08-23 15:22:03 id=65308 trace_id=529 func=init_ip_session_common line=6607 msg="allocate a new session-00000791"
2026-08-23 15:22:03 id=65308 trace_id=529 func=__vf_ip_route_input_rcu line=2116 msg="find a route: flag=00000000 gw-172.31.255.10 via port3"
2026-08-23 15:22:03 id=65308 trace_id=529 func=__iprope_tree_check line=529 msg="gnum-100004, use int hash, slot=31, len=4"
2026-08-23 15:22:03 id=65308 trace_id=529 func=fw_forward_handler line=1014 msg="Allowed by Policy-1:"
2026-08-23 15:22:03 id=65308 trace_id=529 func=ip_session_confirm_final line=3357 msg="npu_state=0x1, hook=4"
2026-08-23 15:22:04 id=65308 trace_id=530 func=print_pkt_detail line=6393 msg="vd-root:0 received a packet(proto=1, 192.168.1.231:1->10.10.100.30:2048) tun_id=0.0.0.0 from wan1. type=8, code=0, id=1, seq=1422."
2026-08-23 15:22:04 id=65308 trace_id=530 func=resolve_ip_tuple_fast line=6501 msg="Find an existing session, id-00000791, original direction"
2026-08-23 15:22:04 id=65308 trace_id=530 func=npu_handle_session44 line=1460 msg="Trying to offloading session from wan1 to port3, skb.npu_flag=00000400 ses.state=04000204 ses.npu_state=0x00000001"
2026-08-23 15:22:04 id=65308 trace_id=530 func=fw_forward_dirty_handler line=445 msg="state=04000204, state2=00000001, npu_state=00000001"
2026-08-23 15:22:06 bfd_nl_event: processing nl event...
2026-08-23 15:22:06 bfd_nl_event: processing nl event type=1 pid=1628
2026-08-23 15:22:06 bfd_session_send_packet_now: Session[172.31.255.1->172.31.255.10,49166,port3,F]: bfd_session_send_packet_now
2026-08-23 15:22:06 bfd_nl_process_register: reg 172.31.255.1->172.31.255.10 ifindex=7
2026-08-23 15:22:06 bfd_session_change_state: Session[172.31.255.1->172.31.255.10,49166,port3,F]: state DOWN -> UP local_diag=0x00
2026-08-23 15:22:06 bfd_session_send_packet_now: Session[172.31.255.1->172.31.255.10,49166,port3,F]: bfd_session_send_packet_now
2026-08-23 15:22:06 bfd_read_ipv4: Session[172.31.255.1->172.31.255.10,49166,port3,F]: received BFD packet
2026-08-23 15:22:06 bfd_read_ipv4: Session[172.31.255.1->172.31.255.10,49166,port3,F]: received BFD packet
2026-08-23 15:22:06 bfd_write:Session[172.31.255.1->172.31.255.10,49166,port3,F]: Sending BFD packet
2026-08-23 15:22:06 bfd_read_ipv4: Session[172.31.255.1->172.31.255.10,49166,port3,F]: received BFD packet
2026-08-23 15:22:06 id=65308 trace_id=531 func=print_pkt_detail line=6393 msg="vd-root:0 received a packet(proto=1, 192.168.1.231:1->10.10.100.30:2048) tun_id=0.0.0.0 from wan1. type=8, code=0, id=1, seq=1423."
2026-08-23 15:22:06 id=65308 trace_id=531 func=resolve_ip_tuple_fast line=6501 msg="Find an existing session, id-00000791, original direction"
2026-08-23 15:22:06 id=65308 trace_id=531 func=npu_handle_session44 line=1460 msg="Trying to offloading session from wan1 to port3, skb.npu_flag=00000400 ses.state=04000204 ses.npu_state=0x00000001"
2026-08-23 15:22:06 id=65308 trace_id=531 func=fw_forward_dirty_handler line=445 msg="state=04000204, state2=00000001, npu_state=00000001"
2026-08-23 15:22:06 bfd_read_ipv4: Session[172.31.255.1->172.31.255.10,49166,port3,F]: received BFD packet
2026-08-23 15:22:06 bfd_write:Session[172.31.255.1->172.31.255.10,49166,port3,F]: Sending BFD packet
2026-08-23 15:22:06 bfd_read_ipv4: Session[172.31.255.1->172.31.255.10,49166,port3,F]: received BFD packet
2026-08-23 15:22:06 bfd_write:Session[172.31.255.1->172.31.255.10,49166,port3,F]: Sending BFD packet
2026-08-23 15:22:06 bfd_read_ipv4: Session[172.31.255.1->172.31.255.10,49166,port3,F]: received BFD packet
2026-08-23 15:22:06 bfd_write:Session[172.31.255.1->172.31.255.10,49166,port3,F]: Sending BFD packet
2026-08-23 15:22:07 bfd_write:Session[172.31.255.1->172.31.255.10,49166,port3,F]: Sending BFD packet
2026-08-23 15:22:07 bfd_read_ipv4: Session[172.31.255.1->172.31.255.10,49166,port3,F]: received BFD packet
2026-08-23 15:22:07 bfd_nl_event: processing nl event...
2026-08-23 15:22:07 bfd_nl_event: processing nl event type=1 pid=1628
2026-08-23 15:22:07 bfd_session_send_packet_now: Session[172.31.254.1->172.31.254.10,49167,port4,10]: bfd_session_send_packet_now
2026-08-23 15:22:07 bfd_nl_process_register: reg 172.31.254.1->172.31.254.10 ifindex=8
2026-08-23 15:22:07 bfd_session_change_state: Session[172.31.254.1->172.31.254.10,49167,port4,10]: state DOWN -> INIT local_diag=0x00
2026-08-23 15:22:07 bfd_session_send_packet_now: Session[172.31.254.1->172.31.254.10,49167,port4,10]: bfd_session_send_packet_now
2026-08-23 15:22:07 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49167,port4,10]: received BFD packet
2026-08-23 15:22:07 bfd_session_change_state: Session[172.31.254.1->172.31.254.10,49167,port4,10]: state INIT -> UP local_diag=0x00
2026-08-23 15:22:07 bfd_session_send_packet_now: Session[172.31.254.1->172.31.254.10,49167,port4,10]: bfd_session_send_packet_now
2026-08-23 15:22:07 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49167,port4,10]: received BFD packet
2026-08-23 15:22:07 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49167,port4,10]: received BFD packet
2026-08-23 15:22:07 bfd_write:Session[172.31.255.1->172.31.255.10,49166,port3,F]: Sending BFD packet
2026-08-23 15:22:07 bfd_write:Session[172.31.254.1->172.31.254.10,49167,port4,10]: Sending BFD packet
2026-08-23 15:22:07 bfd_read_ipv4: Session[172.31.255.1->172.31.255.10,49166,port3,F]: received BFD packet
2026-08-23 15:22:07 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49167,port4,10]: received BFD packet
2026-08-23 15:22:07 bfd_read_ipv4: Session[172.31.255.1->172.31.255.10,49166,port3,F]: received BFD packet
2026-08-23 15:22:07 bfd_write:Session[172.31.255.1->172.31.255.10,49166,port3,F]: Sending BFD packet
2026-08-23 15:22:07 bfd_write:Session[172.31.254.1->172.31.254.10,49167,port4,10]: Sending BFD packet
2026-08-23 15:22:07 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49167,port4,10]: received BFD packet
2026-08-23 15:22:07 bfd_read_ipv4: Session[172.31.255.1->172.31.255.10,49166,port3,F]: received BFD packet
2026-08-23 15:22:07 bfd_write:Session[172.31.255.1->172.31.255.10,49166,port3,F]: Sending BFD packet
2026-08-23 15:22:07 bfd_write:Session[172.31.254.1->172.31.254.10,49167,port4,10]: Sending BFD packet
2026-08-23 15:22:07 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49167,port4,10]: received BFD packet
2026-08-23 15:22:07 id=65308 trace_id=532 func=print_pkt_detail line=6393 msg="vd-root:0 received a packet(proto=1, 192.168.1.231:1->10.10.100.30:2048) tun_id=0.0.0.0 from wan1. type=8, code=0, id=1, seq=1424."
2026-08-23 15:22:07 id=65308 trace_id=532 func=resolve_ip_tuple_fast line=6501 msg="Find an existing session, id-00000791, original direction"
2026-08-23 15:22:07 id=65308 trace_id=532 func=ipv4_fast_cb line=53 msg="enter fast path"
Now, why does this ping loss not happen with SNAT enabled, especially when the debug tells a different story? I genuinely do not know, but that is what I have observed.
In the first line, the inactivity timer expires, and the BFD session for port4 (ISP2) goes down. On the next packet, it takes a new route (via port3, i.e. ISP1), with new SNAT, but gets dropped because the SNAT IP is wrong. This gets corrected on the next packet, is allowed by policy 1, and forwarding happens as we expect.
Here is the configuration at this point:
Case 3 configuration for cluster and ISPs
!!!HA CLUSTER!!!
config system global
set snat-route-change enable
end
config system ha
set route-ttl 30
end
config system settings
set bfd enable
end
config router bgp
set as 65001
set router-id 192.0.2.1
set ebgp-multipath enable
set additional-path enable
set scan-time 5
config neighbor
edit "172.31.255.10"
set advertisement-interval 3
set bfd enable
set capability-graceful-restart enable
set soft-reconfiguration enable
set interface "port3"
set remote-as 65003
set connect-timer 1
set update-source "port3"
next
edit "172.31.254.10"
set advertisement-interval 3
set bfd enable
set capability-graceful-restart enable
set soft-reconfiguration enable
set interface "port4"
set remote-as 65004
set connect-timer 1
set update-source "port4"
next
end
config network
edit 1
set prefix 192.168.1.0 255.255.255.0
next
end
end
!!!ISP1!!!
config system settings
set bfd enable
end
config router bgp
set as 65003
set router-id 192.0.2.3
set scan-time 5
config neighbor
edit "172.31.255.1"
set advertisement-interval 3
set bfd enable
set capability-graceful-restart enable
set capability-default-originate enable
set soft-reconfiguration enable
set interface "SW_BGP"
set remote-as 65001
set connect-timer 1
set update-source "SW_BGP"
next
end
end
!!!ISP2!!!
config system settings
set bfd enable
end
config router bgp
set as 65003
set router-id 192.0.2.3
set scan-time 5
config neighbor
edit "172.31.254.1"
set advertisement-interval 3
set bfd enable
set capability-graceful-restart enable
set capability-default-originate enable
set soft-reconfiguration enable
set interface "SW_BGP"
set remote-as 65001
set connect-timer 1
set update-source "SW_BGP"
next
end
end
I don’t want to spend too much time figuring out why this behaviour is as it is, because we can do better and that requires a static BFD neighbour.
You can read the explanation, but the two important parts are:
Static BFD neighbors will start in the INIT state instead of DOWN
If a discriminator (discr) is received that doesn’t exist, the BFD packet isn’t discarded, but a new session is intialized
In practice, this means that BFD sessions are immediately up on the new primary if an HA failover is performed and, in turn, the BGP session isn’t being taken down.
Two things of note with static BFD:
The neighbor needs to be in the same subnet as the port the neighbor is configured on.
This becomes a problem in a later case.
It is enough to configure static BFD on the FortiGate cluster; you do not need to configure it on any other device to benefit from it.
This point is especially helpful, because if it would require such a configuration on both sides, it would become less useful.
Here is a debug of this behaviour:
70G-BGP1(Secondary) # 2026-08-22 17:43:14 bfd_ha_event: Got HA MESSAGE, msg->nvdoms=1
2026-08-22 17:43:14 bfd_ha_event: HA state vfid=0 old=78336 new=8704
2026-08-22 17:43:14 bfd_ha_event: vd=root is master
2026-08-22 17:43:14 bfd_nl_event: processing nl event...
2026-08-22 17:43:14 bfd_nl_event: processing nl event type=5 pid=1654
2026-08-22 17:43:15 bfd_packet_process: Can't find session with discr 0x00000004
2026-08-22 17:43:15 bfd_packet_process: Choose session with discr 0x00000008 for 0x00000004
2026-08-22 17:43:15 bfd_session_change_state: Session[172.31.254.1->172.31.254.10,49159,port4,8]: state INIT -> UP local_diag=0x00
2026-08-22 17:43:15 bfd_session_send_packet_now: Session[172.31.254.1->172.31.254.10,49159,port4,8]: bfd_session_send_packet_now
2026-08-22 17:43:15 bfd_read_ipv4: Session[172.31.254.1->172.31.254.10,49159,port4,8]: received BFD packet
2026-08-22 17:43:15 bfd_packet_process: Can't find session with discr 0x00000003
2026-08-22 17:43:15 bfd_packet_process: Choose session with discr 0x00000007 for 0x00000003
2026-08-22 17:43:15 bfd_session_change_state: Session[172.31.255.1->172.31.255.10,49158,port3,7]: state INIT -> UP local_diag=0x00
2026-08-22 17:43:15 bfd_session_send_packet_now: Session[172.31.255.1->172.31.255.10,49158,port3,7]: bfd_session_send_packet_now
The HA event gets triggered, no session is being found with discr 0x00000004, and the session with discr 0x00000008 is chosen. The BFD neighbor on port4 (ISP2) then transitions from INIT to UP, and the same process happens on port3 (ISP1)
It is a bit pointless to show, but the BFD sessions are immediately up on the new primary, as we expect from the way static BFD works. BGP needs to re-establish, however, so keep that in mind when configuring route-ttl.
There isn’t that much to say here. I can already tell you that this will be one of the recommended configurations, except for one thing that gets mentioned in the “Can one do better?” section.
Case 4 configuration for cluster and ISPs
!!!HA CLUSTER!!!
config system global
set snat-route-change enable
end
config system settings
set bfd enable
end
config router bfd
config neighbor
edit 172.31.254.10
set interface "port4"
next
edit 172.31.255.10
set interface "port3"
next
end
end
config router bgp
set as 65001
set router-id 192.0.2.1
set ebgp-multipath enable
set additional-path enable
set scan-time 5
config neighbor
edit "172.31.255.10"
set advertisement-interval 3
set bfd enable
set capability-graceful-restart enable
set soft-reconfiguration enable
set link-down-failover enable
set interface "port3"
set remote-as 65003
set connect-timer 1
set update-source "port3"
next
edit "172.31.254.10"
set advertisement-interval 3
set bfd enable
set capability-graceful-restart enable
set soft-reconfiguration enable
set interface "port4"
set remote-as 65004
set connect-timer 1
set update-source "port4"
next
end
config network
edit 1
set prefix 192.168.1.0 255.255.255.0
next
end
end
!!!ISP1!!!
config system settings
set bfd enable
end
config router bgp
set as 65003
set router-id 192.0.2.3
set scan-time 5
config neighbor
edit "172.31.255.1"
set advertisement-interval 3
set bfd enable
set capability-graceful-restart enable
set capability-default-originate enable
set soft-reconfiguration enable
set link-down-failover enable
set interface "SW_BGP"
set remote-as 65001
set connect-timer 1
set update-source "SW_BGP"
next
end
end
!!!ISP2!!!
config system settings
set bfd enable
end
config router bgp
set as 65004
set router-id 192.0.2.4
set scan-time 5
config neighbor
edit "172.31.254.1"
set advertisement-interval 3
set bfd enable
set capability-graceful-restart enable
set capability-default-originate enable
set soft-reconfiguration enable
set link-down-failover enable
set interface "SW_BGP"
set remote-as 65001
set connect-timer 1
set update-source "SW_BGP"
next
end
end
Case 5: GR enabled, BGP on loopback, BFD enabled in BGP with multihop, switched links
So, you configured BGP on loopbacks. Great, I love loopbacks, but it comes with a bit of baggage. First, you have to configure BFD multihop; otherwise, BFD will not establish. Second, you cannot configure static BFD.
As mentioned in the static BFD section, the neighbors need to be in the same subnet as the interface you configure them on. Configuring loopbacks with anything but a /32 leads to hilarious results, like the FortiGate answering to its own pings and trying to establish a BGP peering with itself.
Here is an example on the HA cluster (loopback IP 192.0.2.1)
70G-BGP1(Primary) # execute ping 192.0.2.3
PING 192.0.2.3 (192.0.2.3): 56 data bytes
64 bytes from 192.0.2.3: icmp_seq=0 ttl=255 time=0.0 ms
64 bytes from 192.0.2.3: icmp_seq=1 ttl=255 time=0.0 ms
64 bytes from 192.0.2.3: icmp_seq=2 ttl=255 time=0.0 ms
64 bytes from 192.0.2.3: icmp_seq=3 ttl=255 time=0.0 ms
64 bytes from 192.0.2.3: icmp_seq=4 ttl=255 time=0.0 ms
--- 192.0.2.3 ping statistics ---
5 packets transmitted, 5 packets received, 0% packet loss
round-trip min/avg/max = 0.0/0.0/0.0 ms
[root] BGP: [NETWORK] Accept Thread: Incoming conn from host 192.0.2.1 (FD=30 VRF=0)
[root] BGP: [NETWORK] Accept Thread: 192.0.2.1 - No such Peer configured
Note the constant RTT of 0.0 ms, despite pinging a different host, and the incoming peer connection from 192.0.2.1.
This means static BFD is out of the question.
On to the results.
Results:
HA failover:
1 ping lost with SNAT
6 pings lost without SNAT
ISP failover:
4 pings lost with SNAT
3 pings lost without SNAT
This case is overall very similar to case 3, except it runs on loopbacks.
Case 5 configuration for cluster and ISPs
!!!HA CLUSTER!!!
config system global
set snat-route-change enable
end
config system ha
set route-ttl 30
end
config system settings
set bfd enable
end
config router bfd
config multihop-template
edit 1
set src 192.0.2.0 255.255.255.0
set dst 192.0.2.0 255.255.255.0
next
end
end
config router bgp
set as 65001
set router-id 192.0.2.1
set ebgp-multipath enable
set additional-path enable
set scan-time 5
config neighbor
edit "192.0.2.3"
set advertisement-interval 3
set bfd enable
set capability-graceful-restart enable
set ebgp-enforce-multihop enable
set soft-reconfiguration enable
set interface "LO_BGP"
set remote-as 65003
set connect-timer 1
set update-source "LO_BGP"
next
edit "192.0.2.4"
set advertisement-interval 3
set bfd enable
set capability-graceful-restart enable
set ebgp-enforce-multihop enable
set soft-reconfiguration enable
set interface "LO_BGP"
set remote-as 65004
set connect-timer 1
set update-source "LO_BGP"
next
end
config network
edit 1
set prefix 192.168.1.0 255.255.255.0
next
end
end
!!!ISP1!!!
config router bfd
config multihop-template
edit 1
set src 192.0.2.0 255.255.255.0
set dst 192.0.2.0 255.255.255.0
next
end
end
config router bgp
set as 65003
set router-id 192.0.2.3
set scan-time 5
config neighbor
edit "192.0.2.1"
set advertisement-interval 3
set bfd enable
set capability-graceful-restart enable
set capability-default-originate enable
set ebgp-enforce-multihop enable
set soft-reconfiguration enable
set interface "LO_BGP"
set remote-as 65001
set connect-timer 1
set update-source "LO_BGP"
next
end
end
!!!ISP2!!!
config router bfd
config multihop-template
edit 1
set src 192.0.2.0 255.255.255.0
set dst 192.0.2.0 255.255.255.0
next
end
end
config router bgp
set as 65004
set router-id 192.0.2.4
set scan-time 5
config neighbor
edit "192.0.2.1"
set advertisement-interval 3
set bfd enable
set capability-graceful-restart enable
set capability-default-originate enable
set ebgp-enforce-multihop enable
set soft-reconfiguration enable
set interface "LO_BGP"
set remote-as 65001
set connect-timer 1
set update-source "LO_BGP"
next
end
end
No Graceful Restart cases
Let’s see what happens if GR is disabled, because the results are surprising.
Case 6: GR disabled, BGP on loopback, BFD enabled in BGP with multihop, switched links
On an HA failover, BFD and BGP go down, and once the BGP session is up, traffic is being forwarded once again.
SNAT continues to be in its own world on an HA failover.
Case 3 configuration for cluster and ISPs
!!!HA CLUSTER!!!
config system global
set snat-route-change enable
end
config system ha
set route-ttl 30
end
config system settings
set bfd enable
end
config router bgp
set as 65001
set router-id 192.0.2.1
set ebgp-multipath enable
set additional-path enable
set scan-time 5
config neighbor
edit "172.31.255.10"
set advertisement-interval 3
set bfd enable
set soft-reconfiguration enable
set interface "port3"
set remote-as 65003
set connect-timer 1
set update-source "port3"
next
edit "172.31.254.10"
set advertisement-interval 3
set bfd enable
set soft-reconfiguration enable
set interface "port4"
set remote-as 65004
set connect-timer 1
set update-source "port4"
next
end
config network
edit 1
set prefix 192.168.1.0 255.255.255.0
next
end
end
!!!ISP1!!!
config system settings
set bfd enable
end
config router bgp
set as 65003
set router-id 192.0.2.3
set scan-time 5
config neighbor
edit "172.31.255.1"
set advertisement-interval 3
set bfd enable
set capability-default-originate enable
set soft-reconfiguration enable
set interface "SW_BGP"
set remote-as 65001
set connect-timer 1
set update-source "SW_BGP"
next
end
end
!!!ISP2!!!
config system settings
set bfd enable
end
config router bgp
set as 65003
set router-id 192.0.2.3
set scan-time 5
config neighbor
edit "172.31.254.1"
set advertisement-interval 3
set bfd enable
set capability-default-originate enable
set soft-reconfiguration enable
set interface "SW_BGP"
set remote-as 65001
set connect-timer 1
set update-source "SW_BGP"
next
end
end
Case 7: GR disabled, BGP on interface, BFD enabled in BGP, switched links
HA failover: Complete traffic loss after route-ttl expires
ISP failover: Complete traffic loss
For the HA failover scenario, traffic forwarding works until the synchronized routes expire, so technically you can work around this by increasing the route-ttl setting. BFD sessions are up on the new primary.
During an ISP failover, BGP waits for the hold timer to expire, leading to traffic being blackholed.
There is no difference between SNAT and no-SNAT.
Case 8 configuration for cluster and ISPs
!!!HA CLUSTER!!!
config system global
set snat-route-change enable
end
config system settings
set bfd enable
end
config router bfd
config neighbor
edit 172.31.254.10
set interface "port4"
next
edit 172.31.255.10
set interface "port3"
next
end
end
config router bgp
set as 65001
set router-id 192.0.2.1
set ebgp-multipath enable
set additional-path enable
set scan-time 5
config neighbor
edit "172.31.255.10"
set advertisement-interval 3
set soft-reconfiguration enable
set link-down-failover enable
set interface "port3"
set remote-as 65003
set connect-timer 1
set update-source "port3"
next
edit "172.31.254.10"
set advertisement-interval 3
set soft-reconfiguration enable
set link-down-failover enable
set interface "port4"
set remote-as 65004
set connect-timer 1
set update-source "port4"
next
end
config network
edit 1
set prefix 192.168.1.0 255.255.255.0
next
end
end
!!!ISP1!!!
config system settings
set bfd enable
end
config router bgp
set as 65003
set router-id 192.0.2.3
set scan-time 5
config neighbor
edit "172.31.255.1"
set advertisement-interval 3
set bfd enable
set capability-default-originate enable
set soft-reconfiguration enable
set link-down-failover enable
set interface "SW_BGP"
set remote-as 65001
set connect-timer 1
set update-source "SW_BGP"
next
end
end
!!!ISP2!!!
config system settings
set bfd enable
end
config router bgp
set as 65004
set router-id 192.0.2.4
set scan-time 5
config neighbor
edit "172.31.254.1"
set advertisement-interval 3
set bfd enable
set capability-default-originate enable
set soft-reconfiguration enable
set link-down-failover enable
set interface "SW_BGP"
set remote-as 65001
set connect-timer 1
set update-source "SW_BGP"
next
end
end
Case 9: GR disabled, BGP on interface, BFD enabled in BGP and static BFD, switched links
The ISPs never lose the routes during an HA failover, by the way.
This is a contender for the best configuration.
Case 9 configuration for cluster and ISPs
!!!HA CLUSTER!!!
config system global
set snat-route-change enable
end
config system settings
set bfd enable
end
config router bfd
config neighbor
edit 172.31.254.10
set interface "port4"
next
edit 172.31.255.10
set interface "port3"
next
end
end
config router bgp
set as 65001
set router-id 192.0.2.1
set ebgp-multipath enable
set additional-path enable
set scan-time 5
config neighbor
edit "172.31.255.10"
set advertisement-interval 3
set bfd enable
set soft-reconfiguration enable
set link-down-failover enable
set interface "port3"
set remote-as 65003
set connect-timer 1
set update-source "port3"
next
edit "172.31.254.10"
set advertisement-interval 3
set bfd enable
set soft-reconfiguration enable
set interface "port4"
set remote-as 65004
set connect-timer 1
set update-source "port4"
next
end
config network
edit 1
set prefix 192.168.1.0 255.255.255.0
next
end
end
!!!ISP1!!!
config system settings
set bfd enable
end
config router bgp
set as 65003
set router-id 192.0.2.3
set scan-time 5
config neighbor
edit "172.31.255.1"
set advertisement-interval 3
set bfd enable
set capability-default-originate enable
set soft-reconfiguration enable
set link-down-failover enable
set interface "SW_BGP"
set remote-as 65001
set connect-timer 1
set update-source "SW_BGP"
next
end
end
!!!ISP2!!!
config system settings
set bfd enable
end
config router bgp
set as 65004
set router-id 192.0.2.4
set scan-time 5
config neighbor
edit "172.31.254.1"
set advertisement-interval 3
set bfd enable
set capability-default-originate enable
set soft-reconfiguration enable
set link-down-failover enable
set interface "SW_BGP"
set remote-as 65001
set connect-timer 1
set update-source "SW_BGP"
next
end
end
Can one do better?
BGP settings
I have already set the most relevant BGP timers to low or minimum settings (scan-time, advertisement-interval, and connect-timer), and outside of setting the advertisement-interval to 1, there isn’t anything of relevance we can tweak, that I am aware of at least. The keepalive and holdtime-timer settings should never be relevant in a configured scenario (it would matter for case 8, for example, but you can easily do something better).
BFD timers
I did change the default BFD timers from 250/250/3 to 100/100/2 for a few tests, and I sometimes noticed less traffic disruption, but this is quite difficult to test in a lab environment, because it can just be a fluke, and the lower your BFD timers are, the higher the risk of tearing the session down by accident. Do test it yourself, however and see if it makes a meaningful difference.
FortiGate Session Life Support Protocol (FGSP)
Before starting my tests, I thought that FGSP would be the better option, but now I am not sure. With static BFD, the problem regarding BFD sessions not being synchronized is nonexistent, so I don’t know if FGSP brings any meaningful benefits here. You still need to take care of the first hop on the inside with FGSP, and I don’t know if VRRP will perform better than FGCP.
Disabling hardware offloading
This one’s a doozy. If I disabled hardware offloading in a policy (set auto-asic-offload disable), I consistently lost fewer pings. Obviously, this comes with the problem of traffic not being offloaded, but if you can stomach it for some traffic, this seems like a viable option. The reason for less traffic disruption is probably that everything is being handled in the kernel, instead of the kernel + the NPU, but I am not 100% sure.
I did not test this extensively.
Conclusion and case recommendations
With all that done, what conclusions can we draw?
Enable BFD in BGP and static BFD, if available
You can’t use static BFD with loopbacks
It seemingly does not matter if you enable or disable GR to get the best outcome presented here, but the rest of your configuration needs to be correct.
Static BFD is great
The main decision is: Are you running BGP on your interface or on loopbacks?
If it’s BGP on interface: Look at case 4 or case 9. The only difference is that case 9 does not enable GR. I am more comfortable recommending case 4, because I just feel better with GR enabled.
If it’s BGP on loopback: Look at case 5 or case 6. Again, the only difference is GR not being enabled in one case (6), and I feel better recommending case 5, because GR is enabled.
Wrapping up
This was effectively my first commenter request, which feels good, but this post took way longer than it should have due to my testing process being bad and me constantly coming up with new things to test. A lot of surprises were discovered along the way, and now I’ve got it behind me, and I hope you, dear reader, come away with more, just as I did.
If you are curious, I used Drawbridge to create the diagrams for this post and will do so moving forward. Drawbridge is a project by Per Rosenlind, a good acquaintance. Check it out, and if you like it, support him.
If, dear reader, you ever wanted to see HTTP headers in forwarded traffic through a FortiGate or how to view decrypted traffic without having to mirror it to an external device, have I got some tips for you.
Models and versions:
FortiGate 70G on 7.6.7
Ubuntu server on 24.04
See HTTP headers in FortiGate forwarded traffic
For troubleshooting purposes, it can help to see what HTTP headers are being sent to a host. In my case, I needed to find out what an incoming HTTP POST request looked like to fix my backend configuration.
In order to facilitate this, you need a proxy-based firewall policy that uses some security profile other than the SSL/SSH profile (just the SSL/SSH profile alone means no inspection is performed), because we need the traffic to hit the wad process, and if your traffic is HTTPS, you need deep inspection as well. The type of security profile doesn’t actually matter. I have tested anti-virus, web filter, DNS filter, application control, IPS, file filter, and web application firewall, and all of them worked. For my security profile, I am using a Web Application Firewall (WAF) profile because it requires the least amount of CLI configuration and a deep inspection SSL/SSH profile.
config waf profile
edit "WAF_HTTP-HEADERS"
next
end
With the WAF profile created and your deep inspection set up, if necessary, you can create a proxy-based policy with both profiles configured. Remember that you need “Web Application Firewall” enabled in Feature Visibility to see the GUI option.
Only important parts are shown!
config firewall policy
edit 0
set name "HTTP-HEADERS-POLICY"
set utm-status enable
set inspection-mode proxy
set ssl-ssh-profile "custom-deep-inspection"
set waf-profile "WAF_HTTP-HEADERS"
next
end
Because proxy-based web inspection uses the wad process, we need the debugs for the wad category http enabled and a level of info. Once our traffic happens, the debugs will fill up quickly.
We can see the URL path the POST got sent to, the host, and the HTTP headers, including my authorization bearer token. You can do with this information what you need.
View decrypted traffic on a FortiGate without an external device
The decrypted traffic mirror feature on a FortiGate is an interesting one because it allows you to mirror decrypted traffic to another interface, which can then be captured and inspected by a device, like a dedicated intrusion detection system (IDS). Normally, decrypted traffic is not capturable on a FortiGate itself; you only gain visibility for the internal processes that work with this kind of traffic (IPS, anti-virus, etc.), but with decrypted traffic mirroring and some ingenuity, you can capture this traffic without an external device.
Note: The decrypted traffic mirror feature should be used with caution. Handling this type of traffic can be an invasion of privacy, and you need to check your local laws to see if this is allowed in your region if you are handling relevant traffic (health and finance information are the most critical types). You will see a message reminding you that this is something you need to be aware of, both in the GUI and the CLI.
Now that you are aware of the problems, let’s get to configuring.
If you are configuring a policy with an SSL/SSH profile performing deep inspection, you get the “Decrypted traffic mirror” option, and there you can select an existing mirror port or create one inline. I haven’t tested all options, but I think all types of interfaces can be mirror ports, including VLANs and loopbacks. For this post, I am enabling the feature for a VLAN interface. As long as the interface is up, it is valid, so you can create a dummy VLAN interface on a parent port that is up.
config firewall decrypted-traffic-mirror
edit "DTM_VLAN"
set dstmac ff:ff:ff:ff:ff:ff
set traffic-type ssl ssh
set traffic-source both
set interface "VL_TEST"
next
end
Once your policy is configured accordingly, you can capture traffic on it, either in the GUI via Network -> Diagnostics -> Packet capture or on the CLI using diagnose sniffer packet <INTERFACE> ‘<FILTER>’ 6 0 a, e.g. diagnose sniffer packet VL_TEST ‘host 192.168.1.151’ 6 0 a. The 6 is used so the headers and data from packets are captured along with the interface name.
Only important parts are shown!
config firewall policy
edit 0
set name "DTM-POLICY"
set utm-status enable
set inspection-mode proxy
set ssl-ssh-profile "custom-deep-inspection"
set waf-profile "WAF_HTTP-HEADERS"
set decrypted-traffic-mirror "DTM_VLAN"
next
end
Remember that you need at least one security profile in a policy performing any form of inspection that isn’t the SSL/SSH profile; otherwise, no inspection happens. That is the reason why the WAF profile that I created in the other accompanying quick tip is there.
If you want to view the traffic in Wireshark and you have performed the capture on the GUI, you can download the PCAP immediately, and if you do it on the CLI, you need to capture and convert the output using a Fortinet-provided tool.
Using my previous curl command, just with the --http1.1 option added, the capture shows the HTTP headers as well as the payload. The --http1.1 option is optional, but without it, not all information may be visible due to possible HTTP/2 compression, which webhook.site uses.
Both of these tips are closely related because I worked with both of them due to the same partner request at work, and I thought they were interesting enough for a post. Two people, both from Fortinet, helped me in exploring these tips. Maximilian Schiffner, an often-used source of knowledge, and another person who I am sure prefers to be anonymous, but if you ever needed help with authentication questions in the Fortinet ecosystem, you probably stumbled across something from him.
Dear reader, I have another installment of the quick tips series. This time the topics are FQDN-based split tunneling for IPsec with FortiClient and how to use the URL access feature of a FortiGate Web Application Firewall (WAF) profile.
FQDNs are not supported in split-tunnel destinations. If FQDNs have been configured in the split-tunnel address group, it cannot be applied in the (set ipv4-split-include) config of the dialup IPsec.
That won’t stop us, however.
In order to make this happen, we can use EMS’ application-based split tunneling feature while editing a tunnel. In there, you can include or exclude Cloud and Video Streaming Applications, but also Domains, and that last point is the one that is interesting, because you can use FQDNs here.
In this case, I am adding the FQDN of this blog, blog.guenay.at, to the configuration.
The VPN configuration of a FortiGate doesn’t require any special configuration for this to work, and I have configured a regular PSK VPN with split tunneling, that routes 192.168.0.0/16 into the tunnel.
FortiGate VPN configuration
config vpn ipsec phase1-interface
edit "PSK-VPN"
set type dynamic
set interface "wan1"
set ike-version 2
set keylife 28800
set peertype any
set net-device disable
set mode-cfg enable
set ipv4-dns-server1 192.168.1.169
set proposal aes256gcm-prfsha512
set dhgrp 31
set eap enable
set eap-identity send-request
set authusrgrp "VPN_USERS"
set client-resume enable
set client-resume-interval 600
set transport auto
set ipv4-start-ip 172.16.101.1
set ipv4-end-ip 172.16.101.20
set ipv4-split-include "BASE-RFC1918-192"
set psksecret ENC TaAvnptXQNmMUc/zjoy5F1CweIG8wOZUwliDjZCfvt8lr36rtJl1i95oOzgQjN22lPhP3tvZ0HOLy5XCQNtGvKO+piZ6I2wDKHVXe4Q787D5NNdrtPwonaDXZxrdyAZ5hl3r3am7zJMhWC5F5Pdz/ms5t5oew98CiSjzPUv/FHHYrxObTvi+IrxH+MW5VmUIa85ZEVlmMjY3dkVA
set dpd-retryinterval 5
next
end
config vpn ipsec phase2-interface
edit "PSK-VPN"
set phase1name "PSK-VPN"
set proposal aes256gcm
set dhgrp 31
set keepalive enable
set keylifeseconds 3600
next
end
A policy to allow the WAN-bound traffic from the VPN is, of course, also needed for our FQDN split tunneling to work.
Once the connection on FortiClient is up, we can see, in the log file of the VPN (in my case it is C:\Program Files\Fortinet\FortiClient\logs\trace\iked_ikev2_PSK-VPN7EB027B2_1.log), that split tunneling is active and per-application policies have been applied in include mode.
Lots of routes are being added, and this is reflected in the route table.
If you use the exclude mode in the EMS VPN configuration, you see this in the log, and the routes are added to the routing table using the regular interface with your normal default gateway. Funnily enough, you don’t see these routes being added in the log.
This method of FQDN-based split tunneling is a nice compromise when transitioning from SSL-VPN to IPsec. Don’t forget the Cloud Applications option in this, because it offers a few often-used services and acts like the other options.
FortiGate Web Application Firewall URL access
Due to a blog post about FortiClient EMS Let’s Encrypt Security that I wrote for my employer, I was told about the URL access feature that lives inside the WAF profile of a FortiGate, which I wasn’t aware of before. Outside of the CLI reference, I cannot find any documentation of this feature for a FortiGate, only for FortiWeb, but it is relatively self-explanatory. Still, let’s see how to configure it.
Note: The URL access feature is CLI-only, but you should still enable Web Application Firewall in Feature Visibility, because otherwise the option to assign the profile in a policy does not show up in the GUI. Also, you can do much the same with a web filter profile, which I do in the post above, so the WAF option is not the only method to achieve this; it just saves on another profile.
On the CLI, you can configure a basic URL access block as follows:
config waf profile
edit "WAF_URL-ACCESS"
config url-access
edit 1
set address "ubuntu-ws-1.ad.labdomain.com"
set action block
set log enable
config access-pattern
edit 1
set srcaddr "all"
set pattern ".*/login.html$"
set regex enable
next
end
next
end
next
end
Let’s go through it.
The address option determines the backend host that is being protected, and it is an actual address object. I have tested it with the types ipmask and fqdn, and both worked here, assuming your DNS resolution is correct for the FQDN option.
action has the block option, which does exactly that, bypass, which allows the connection and skips all other WAF scanning, and permit, which allows the connection and continues with WAF scanning.
log is for logging; who would have thought.
In the access-pattern entry itself, we can set the srcaddr option, which determines to which source address this should apply, so we can target only specific addresses. Again, this is an actual address object.
Enabling regex does that, and because I prefer regex-matching over everything else, I have it enabled. Disabling it probably does a simple match on the URL, but I didn’t test this because if regex is available, I will use only that.
The actual regex pattern is used in the pattern option. In my case, I am checking for every URL path that ends in /login.html.
That’s it on how to configure the feature.
Note that there is no implicit deny rule here, so if you don’t have an entry for a pattern, it is allowed. Entries are matched top down, so keep that in mind.
Assign the WAF profile in a policy, and if you perform deep inspection, either with an SSL/SSH profile or a virtual server, you can also control access in HTTPS traffic.
If I test the URL https://ubuntu-ws-1.ad.labdomain.com/login.html on my client, I get the block, as expected, and I see this in my FortiGate Web Application Firewall log.
Browsing to any other URL, like https://ubuntu-ws-1.ad.labdomain.com/index.html or just https://ubuntu-ws-1.ad.labdomain.com, does not lead to a block, because I never explicitly mentioned this in my WAF profile and I also don’t have a catch-all entry.
If you ever need to debug this feature, then you need to know that the WAF feature uses the wad process, and the category http is the important one.
If you want a configuration that allows a specific URL path and blocks all others, you can configure it like this:
config waf profile
edit "WAF_URL-ACCESS"
config url-access
edit 1
set address "ubuntu-ws-1.ad.labdomain.com"
set log enable
config access-pattern
edit 1
set srcaddr "all"
set pattern ".*/login.html$"
set regex enable
next
end
next
edit 2
set address "ubuntu-ws-1.ad.labdomain.com"
set action block
set log enable
config access-pattern
edit 1
set srcaddr "all"
set pattern ".*"
set regex enable
next
end
next
end
next
end
And that’s that feature explained.
Short and sweet and done
The configuration for both things in this quick tips installment isn’t anything to write home about, as it should be for this series, but I haven’t seen much on either of these things, so hopefully this helps at least you, dear reader.
A FortiGate Cluster Protocol (FGCP) HA deployment is nothing new in today’s world, and with more and more of these clusters functioning as BGP routers, especially with the proliferation of SD-WAN and ADVPN, having a high BGP service uptime is becoming critical. In order to achieve this, there are a few things to keep in mind. These things, dear reader, are what this post is about.
A BGP peering in the AS 65001 over the 192.0.2.0/24 subnet has been established between the 70G cluster and the standalone 60F.
Both FortiGate deployments have a client behind them; they are announcing the client subnets, and the 60F is also announcing subnets ranging from 169.254.1.0/24 to 169.254.250.0/24 as a very small stress test.
Note: When I use the word “downstream”, I mean any BGP peer of the HA cluster. This doesn’t mean the peer is actually downstream of the traffic flow. Writing “BGP peer of the HA cluster” every time would be bad writing.
The most important information
You should get the reason for reading this post as quickly as possible, so here it is, if you start with default values:
Enable Graceful Restart either at the BGP global level or per neighbor on both systems
The route-ttl setting of the HA cluster needs to be high enough to allow for full route convergence to happen
Keeping the preceding point in mind, the downstream device needs to have a BGP advertisement-intervalat least lower than the route-ttl value of the HA cluster
The downstream device must not tear down the existing BGP session before the new BGP session has been established on the new primary FortiGate, meaning keep keepalive and holdtime in mind
Basics about FGCP BGP behaviour
Before I get into the meat and potatoes, I want to put out some information on how BGP behaves in an FGCP cluster.
The BGP routing process and thus the BGP neighborships only exist on the primary unit.
Once a failover happens, the new primary has to establish the BGP neighborship again.
Routes are copied from the primary to the secondary at the route-hold interval (default 10 seconds).
route-wait determines how long to wait after the primary recognizes a routing table update until it copies it to the secondary (default 0 seconds, meaning on every routing table update).
Routes are kept in a cluster for the route-ttl duration (default 10 seconds).
The copied routes are visible in the kernel table on the secondary using get router info kernel command. You do not see them with the get router info routing-table database command.
Importantly, once the route-ttl timer has run out and the new primary has not received the kernel routes previously acquired from the BGP peer, again, these kernel routes will be discarded. This behaviour is most likely consistent across all routing protocols, not just BGP.
Here is the regular routing table and the kernel routing table output of the secondary:
With this information in mind, the failover process for BGP looks as follows, with default values:
The cluster performs a failover
The new primary forms a new BGP neighborship
The downstream peer announces its routes
If this happens after the route-ttl timer, connectivity is impacted.
After all routes have been received and installed, normal operations continue
With default values, you will lose connectivity if traffic relies on BGP routing despite the copied routes because of the route-ttl timer, but this will be solved.
More information on route-ttl, route-hold, and route-wait is in the official documentation.
With default values
Assuming we did nothing to our BGP configuration, and we only did the minimum to establish a session, we will observe that upon a failover, the new primary will lose its BGP kernel routes after 10 seconds, the route-ttl timer.
Here is the BGP configuration of the HA cluster and the neighbor output of the downstream peer:
HA cluster BGP configuration and neighbor output of downstream
70G-BGP1(Primary) # show router bgp
config router bgp
set as 65001
set router-id 192.0.2.1
config neighbor
edit "192.0.2.2"
set activate6 disable
set interface "port2"
set remote-as 65001
set update-source "port2"
next
end
config network
edit 1
set prefix 192.168.1.0 255.255.255.0
next
end
config redistribute "connected"
end
config redistribute "rip"
end
config redistribute "ospf"
end
config redistribute "static"
end
config redistribute "isis"
end
config redistribute6 "connected"
end
config redistribute6 "rip"
end
config redistribute6 "ospf"
end
config redistribute6 "static"
end
config redistribute6 "isis"
end
end
60F-DOWNSTREAM # get router info bgp neighbors 192.0.2.1
VRF 0 neighbor table:
BGP neighbor is 192.0.2.1, remote AS 65001, local AS 65001, internal link
BGP version 4, remote router ID 192.0.2.1
BGP state = Established, up for 00:04:31
Last read 00:00:54, hold time is 180, keepalive interval is 60 seconds
Configured hold time is 180, keepalive interval is 60 seconds
Neighbor capabilities:
Route refresh: advertised and received (old and new)
Address family IPv4 Unicast: advertised and received
Address family VPNv4 Unicast: advertised and received
Address family VPNv6 Unicast: advertised and received
Address family L2VPN EVPN: advertised and received
Received 32 messages, 1 notifications, 0 in queue
Sent 35 messages, 0 notifications, 0 in queue
Route refresh request: received 0, sent 0
NLRI treated as withdraw: 0
Minimum time between advertisement runs is 30 seconds
Update source is SW-BGP
For address family: IPv4 Unicast
BGP table version 13, neighbor version 13
Index 1, Offset 0, Mask 0x2
Community attribute sent to this neighbor (both)
1 accepted prefixes, 1 prefixes in rib
1 announced prefixes
It takes about 3 minutes and 30 seconds for the new primary to get all routes. This duration comes from the keepalive timer of the downstream peer, with a default of 180 seconds and an advertisement interval of 30 seconds.
Looking at the debugs from the downstream peer, we see this behaviour (I have tried to align the failover with the last keepalive being received):
At 14:33:29, exactly 3 minutes/180 seconds later, the hold timer expired
At 14:33:35, the HA peer comes up
At 14:34:01, the prefix is received
This is a worst-case scenario. The convergence time can be lower because the hold timer expiration depends on when the last keepalive was successfully received, so you can subtract up to 59 seconds here.
Tweaking timers
Keepalive and hold timer values can be easily changed, and we can do this at both the global and neighbor level on a FortiGate. I have set both timers to their minimum at the global level on both devices. Technically, it’s enough to only do it on the downstream peer. Note that changing these values requires a new BGP session.
config router bgp
set as 65001
set router-id 192.0.2.2
set keepalive-timer 1
set holdtime-timer 3
end
Looking at the debugs on the downstream again, we see that we get our prefix much quicker. It takes around 33 seconds now.
BGP debug with minimum keepalive and holdtimer values
The following timers don’t actually change anything for this scenario, but they are still helpful in regular operations, and I want to highlight them. These are the scan and connect timers.
The connect-timer value determines how long the FortiGate waits before attempting a new BGP connection attempt after the previous one has failed.
The scan-time determines the interval at which the FortiGate scans for next-hop reachability, and if the next hop isn’t reachable, the route gets dropped.
config router bgp
set as 65001
set router-id 192.0.2.2
set scan-time 5
config neighbor
edit "192.0.2.1"
set connect-timer 1
next
end
end
You can read more about these timers and lots of other ones in the official documentation.
Not tearing down the session with Graceful Restart
So we are down to around 8 seconds, and this is with a torn-down session on the downstream side. At this point it’s pointless to try and optimize this approach. We need the session to not get torn down on the downstream peer and keep the routes available on both sides.
If you know BGP, you already know the answer to this: Graceful Restart (GR)
Graceful Restart is designed to keep routes in the Routing Information Base (RIB) even if the peer is down. There are timers around this, but the defaults will be enough for us. Keep in mind that both sides need to support GR for this to work. Every half-decent router should be able to, but I just want to mention it.
GR can be enabled at the global and neighbor level on a FortiGate, and doing so clears your BGP sessions, either globally or only for a neighbor. I enable it on the neighbor.
70G-BGP1(Primary) # show router bgp
onfig router bgp
set as 65001
set router-id 192.0.2.1
config neighbor
edit "192.0.2.2"
set capability-graceful-restart enable
next
end
end
I have also unset all other previous settings, so here is the full BGP config (excluding network statements and redistribute sections) of both devices at this point:
60F-DOWNSTREAM # show router bgp
config router bgp
set as 65001
set router-id 192.0.2.2
config neighbor
edit "192.0.2.1"
set activate6 disable
set capability-graceful-restart enable
set interface "SW-BGP"
set remote-as 65001
set update-source "SW-BGP"
next
end
end
70G-BGP1(Primary) # show router bgp
config router bgp
set as 65001
set router-id 192.0.2.1
config neighbor
edit "192.0.2.2"
set activate6 disable
set capability-graceful-restart enable
set interface "port2"
set remote-as 65001
set update-source "port2"
next
end
end
For this exercise, it’s technically enough to enable GR only for the HA peer, but you might as well do it for both peers.
You can verify the GR capability using the get router info bgp neighbors <NEIGHBOR_IP> command.
BGP neighbor output after graceful restart
60F-DOWNSTREAM # get router info bgp neighbors 192.0.2.1
VRF 0 neighbor table:
BGP neighbor is 192.0.2.1, remote AS 65001, local AS 65001, internal link
BGP version 4, remote router ID 192.0.2.1
BGP state = Established, up for 00:00:23
Last read 00:00:01, hold time is 3, keepalive interval is 1 seconds
Configured hold time is 3, keepalive interval is 1 seconds
Neighbor capabilities:
Route refresh: advertised and received (old and new)
Address family IPv4 Unicast: advertised and received
Address family VPNv4 Unicast: advertised and received
Address family VPNv6 Unicast: advertised and received
Address family L2VPN EVPN: advertised and received
Received 1509 messages, 7 notifications, 0 in queue
Sent 1550 messages, 16 notifications, 0 in queue
Route refresh request: received 0, sent 0
NLRI treated as withdraw: 0
Minimum time between advertisement runs is 1 seconds
Update source is SW-BGP
For address family: IPv4 Unicast
BGP table version 19, neighbor version 18
Index 1, Offset 0, Mask 0x2
AF-dependant capabilities:
Graceful restart: advertised, received, negotiated
Forwarding states are being preserved
Community attribute sent to this neighbor (both)
1 accepted prefixes, 1 prefixes in rib
1 announced prefixes
For address family: VPNv4 Unicast
BGP table version 1, neighbor version 1
Index 1, Offset 0, Mask 0x2
Community attribute sent to this neighbor (both)
0 accepted prefixes, 0 prefixes in rib
0 announced prefixes
For address family: VPNv6 Unicast
BGP table version 1, neighbor version 1
Index 1, Offset 0, Mask 0x2
Community attribute sent to this neighbor (both)
0 accepted prefixes, 0 prefixes in rib
0 announced prefixes
For address family: L2VPN EVPN
BGP table version 1, neighbor version 1
Index 1, Offset 0, Mask 0x2
Community attribute sent to this neighbor (both)
0 accepted prefixes, 0 prefixes in rib
0 announced prefixes
Connections established 24; dropped 23
Graceful-restart Status:Remote restart-time is 120 sec
We see Graceful restart: advertised, received, negotiated for the IPv4 Unicast address family, and we also see some additional information at the bottom regarding the remote restart time.
With GR enabled, our BGP routing behaviour is much better. It’s not interesting to show any debugs, but on a failover, traffic forwarding works fully up until the route-ttl timer, default of 10 seconds, is over (this behaviour was explained further above). At that point, connectivity is lost until the routes get announced again.
We can work and fix this with our acquired knowledge.
The last tweaks
Now we need to make sure two things happen:
Routes are announced more often
Routes are kept in the cluster’s kernel routing table for longer, or at least until we get the new routes
We know the answer to both, and the answers are advertisement-interval and route-ttl.
If we set the advertisement interval on the downstream at least lower than the route TTL of the cluster, everything should be fine. So an interval of 3 seconds with a TTL of 10 should be enough. This is theoretically correct, but it also depends on your environment. If you expect to receive a lot of routes, it might take longer to ingest them all, so consider upping the TTL past the default, or reducing the advertisement interval, or doing both.
My downstream peer is only announcing 251 routes, and with an interval of 3 and a TTL of 10, I see zero traffic loss.
The final configuration
This doesn’t differ much from the configuration posted in the GR section, except that the advertisement interval is now set to something other than the default. The route TTL is at its default of 10, but it’s still shown.
60F-DOWNSTREAM # show router bgp
config router bgp
set as 65001
set router-id 192.0.2.2
config neighbor
edit "192.0.2.1"
set advertisement-interval 3
set activate6 disable
set capability-graceful-restart enable
set interface "SW-BGP"
set remote-as 65001
set update-source "SW-BGP"
next
end
end
70G-BGP1(Primary) # show router bgp
config router bgp
set as 65001
set router-id 192.0.2.1
config neighbor
edit "192.0.2.2"
set activate6 disable
set capability-graceful-restart enable
set interface "port2"
set remote-as 65001
set update-source "port2"
next
end
end
config system ha
set route-ttl 10
end
With this configuration, I was able to perform an HA failover without losing any pings or interruption of a file transfer between the downstream client and the HA client.
You can adapt this configuration with the scan and connect timers, and of course whatever else you need, but be careful about the hold and keepalive timers, lest you tear down the BGP session.
I heard about BFD
No. Bidirectional Forwarding Detection (BFD) is a great feature that helps with detecting failures in routing sessions, but in this case, it does the opposite of what we want. If BFD detects that its session is down, it will also tear down the BGP session, which would lead to traffic loss.
When it comes to combining BFD with GR, you have to consult the vendor documentation because some vendors might support this combination. Start with the assumption that this is not supported; however, Fortinet explicitly does not recommend it, as can be read here and here.
Use BFD with caution. Combining BFD with graceful restart is not recommended by Fortinet. Other vendors also explicitly mention not to combine them in configuration, as it might cause suboptimal routing performances when graceful restart and BFD are both configured.
BGP graceful restart or OSPF graceful restart doesn’t work with BFD, and it is not recommended.
[Update 2026-08-23]: As part of my post on multi-homed FortiGate FGCP HA BGP failover behaviour, I also tested the behaviour with BFD enabled in a single-homed scenario. For my test, I was using a ping with a timeout of 100 milliseconds, not the standard of 4 seconds I was using for this article (ping 192.0.2.1 -t -w 100). The result with regular BGP BFD was 5 lost pings, with BFD going down and needing to be re-established, same as BGP. With BFD enabled BGP graceful restart, no pings were lost. One can debate the logic of this configuration, but it works.
Wrapping up
The fact that graceful restart was the solution to this problem wasn’t a surprise, because that’s what it is designed for, but the behaviour with route-ttl was new to me, and I am glad that I spent the time creating this post, because I have definitely learned something new. I hope that you, dear reader, have also learned something.
I am not a fan of the official FortiClient EMS API documentation that is available on the Fortinet Developer Network (FNDN). It is a bare-bones documentation that is sparse on explaining how to interact with the API, has very few examples, no responses, lacks a lot of API endpoints, and the endpoints that exist are badly documented (I challenge you to try creating a ZTNA tag with an associated rule using only the documentation).
To offer a bit of help to you, dear reader, in this regard, I went through the most common configurations when working with EMS, how you accomplish these tasks using the API, and I will also show you how you can help yourself when working with the API.
The setup
FortiGate 70G on 7.6.7
FortiClient EMS on 7.4.7
2x Windows 11 client on 25H2 with FortiClient 7.4.7
All the API examples I give will be done using Python. Everything featured in this post will also be on the Fortinet resources GitHub repository.
I am using an on-prem EMS. Cloud EMS is a bit different, especially when it comes to the login, and you have to adapt your own scripts accordingly. See the official documentation for Cloud EMS.
The most important thing: Reverse engineering the API
I want to give the best advice first, and in this case it’s getting comfortable with reverse engineering API calls.
In practice, this means that you do something in the GUI and simultaneously use the browser tools to find out what happens, because most actions in the GUI create the exact API call you need to accomplish this in your API tool of choice, be it Python, cURL, or anything of the like.
Let’s take the example mentioned in the intro: Creating a ZTNA tag with an associated rule
As we can see, I have this ZTNA tag named BROWSER-TAG-NAME, with a user notification message, a comment, and a rule that validates on FortiClient that the user is part of the AD group ZTNA_USERS.
Once I hit save on this and with my browser tools open and recording, I can see, on Chrome, in the Network tab, a create call. In the Headers tab of this call, I can see the Request URLhttps://192.168.1.208/api/v1/tags/zero_trust/create and the Request Method of POST.
On the Payload tab, after clicking on View Source, I can see the full JSON payload.
The Response tab also shows the response, which isn’t that important.
With these three pieces of information, the URL, method and payload, I can recreate this API call and find out what all the information in the JSON payload means.
This approach of reverse engineering is, with the current state of the official documentation, invaluable.
Now let’s get to the meat of this post.
AI usage disclosure
There is a function called deep_merge, which I will call out when it is used, that is purely written by Claude Sonnet 4.6. It is used to update a dictionary with new information, also called a deep merge. I did this because it would have taken me too much time to do it myself, and on that day, it was too late, and I just wanted to get this part done.
Every other piece of code you see in this post is written by me.
How I structure this
Just so I don’t have to repeat myself, I will explain how I approach each section concerning the example I give.
I will start with an explanation of what is being done and some additional information where required, embed the Python script I have made (again, check the GitHub repository if you want to have it all in one place), post the response EMS gives for the relevant API call, and write some more about it, if there is anything to write about.
All the Python scripts are, hopefully, well commented. Most of the things being done are basic, and only some parts require special attention, which are explained in more detail.
I am not good at Python, so if you believe any of my scripts are bad, please keep that in mind.
Logging in, getting your token and logging out.
Keep in mind that this is for on-prem EMS. Cloud EMS handles logging in differently.
The first step you need to make when you want to work with the FortiClient EMS API is logging in, and for that, you need an administrator. There is nothing special about this administrator, so on EMS, head to Administration -> Admin Users and add your user with the permissions you need. Setting Trusted Hosts is a good idea.
Once you log in to the API, you get a Cross-Site Request Forgery (CSRF) token in your cookies. It is important to know that this token is in your cookies; it is not sent in the response you get after logging in.
This token has to be used for various API endpoints when you create, update or delete information. Endpoints where you only GET information usually don’t need this token.
After you are done with whatever you need to do using the API, it is common courtesy to perform a logout, where you supply your token.
Login, get token, logout
'''
ems_login_token_logout.py
Perform a login on the FortiClient EMS API, get the CSRF token and then logout
'''
import requests
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Disable warnings
requests.urllib3.disable_warnings()
#Set some variables for the API
ems_server = '192.168.1.208'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
In order for FortiClient EMS to share endpoint information and ZTNA tag information with a FortiGate, the FortiGate needs to first get authorized. After that, you have to decide what tags you want to share and from which endpoints. Often, you want to share Security Posture Tags, and you want to Share All FortiClients, and that is what this script does.
Authorize and edit FortiGate
'''
ems_fgt_authorization_share_clients.py
Authorize a FortiGate and set properties on it using the FortiClient EMS API
'''
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
fgt_serial = 'FGT70GSERIAL'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
fgt_authorization_url = f'{api_url_prefix}/client_certificates/set'
fgt_properties_url = f'{api_url_prefix}/fabric_device_auth/{fgt_serial}/update'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
fgt_authorization_data = {"filters": {"management_mode": "standalone", "cns": [f"{fgt_serial}"]}, "properties": {"authorized": True}}
#share_mode 0 is "Only share FortiClients connected to this fabric device (Recommended)"
#share_mode 1 is "Share all FortiClients"
#share_tag_types 1 is "Security Posture Tags", 2 is "Outbreak Tags", 3 is "Classification Tags", 4 is "Fabric Tags"
fgt_properties_data = {"share_mode":1,"selected_cn_list":[],"share_tag_types":[1],"alias":"FGT-EMSAPI"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Authorize the FortiGate and set properties
session.patch(url=fgt_authorization_url, json=fgt_authorization_data, headers=change_headers, verify=False, timeout=30)
session.patch(url=fgt_properties_url, json=fgt_properties_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Skipping creating an authentication server, eh? Yes. The reason is that creating an authentication server is form-based and I couldn’t find a way to do this using the API, especially when it comes to AD with LDAPS with a certificate upload. Sorry.
But I know how to create a domain import, which isn’t that easy either, thanks to how EMS handles authentication servers. A short explanation of this.
EMS collects all authentication servers as Identity Providers (IDPs) in the /api/v1/idps/index endpoint, which you can perform a GET on, but creating it is done via a form on the /api/v1/idps/adfs/create endpoint using a POST.
Each IDP has a GUID, and you need this GUID to perform a domain import, because you first have to perform a walk on the directory structure to get the top-level objects (OUs and containers in most cases) and their information (GUID, name, DN and path). If your journey stops there and you just want to import from these top-level objects, you’re done. If you want to import individual groups, for example, in an OU, you have to go down a rabbit hole, because you have to get the GUID of that OU, perform a directory walk on that OU, get the information of the groups therein and get the information for the import (the same as for the OU).
In an ordered list:
Get IDP/authentication server GUID
Perform a directory walk using the IDP GUID to find top-level objects
Find information on relevant top-level objects
Optionally, perform a directory walk of top-level objects to find sublevel objects
Optionally, get information on relevant sublevel objects
The script handles this case, and there is an example directory structure given, and if you reverse engineer the API process, it will start to make more sense than what I have presented here.
Create domain import
'''
ems_create_domain_import.py
Create a domain import using the FortiClient EMS API
'''
import urllib.parse
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
idp_name = "ad.labdomain.com"
#Adding groups and OUs to a policy is a bit complicated, so here is an example.
#Consider the following AD structure:
#ad.labdomain.com
#└── CLIENTS
#└── GROUPS
# ├── VPN_USERS
# ├── ZTNA_USERS
#└── SERVERS
# └── PROD
#If you want to import the entire CLIENTS OU add the name to the assigned_ous list
#The script will get the required information of the OU and create a dictionary for the import
#If you want to import the VPN_USERS group, and using the structure from above, you first have add the parent OU to the parent_ous list
#In the example the parent OU is GROUP
#Then add the VPN_USERS group to the assigned_group_names list
assigned_ous = ['CLIENTS','SERVERS']
parent_ous = ['GROUPS']
assigned_group_names = ['VPN_USERS','ZTNA_USERS']
assigned_ous_groups = []
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
idps_url = f'{api_url_prefix}/idps/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get authentication servers data
response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get all the necessary information directly from the IDP and create the data for the domain import
for idp in response_decoded['data']:
if idp['domain_info']['name'] == idp_name:
idp_id = idp['domain_info']['guid']
#The DN has to be URL encoded
idp_dn_urlencoded = urllib.parse.quote(idp['connection_info']['basedn'], safe="")
domain_import_url = f'{api_url_prefix}/idps/adfs/{idp_id}/patch'
#The live navigation is used to get the information regarding the directory structure straight from the authentication server
live_navigation_url = f'{api_url_prefix}/idps/{idp_id}/live_navigate?dn={idp_dn_urlencoded}'
#The response from the live navigation contains the directory structure with all OUs, but not groups
response = session.get(url=live_navigation_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#The two ifs are used to search for the OU name in the assigned OUs, if we want to import an entire OU, and search through the parent OUs if we need to import groups in OUs
for ou in response_decoded['data']:
if ou['name'] in assigned_ous:
#A temporary dictionary is used to store the information for the to-be-imported object
temp_dict = {"guid": ou['guid'], "name": ou['name'], "dn": ou['dn'], "path": ou['canonical_name'], }
#The temporary dictionary gets added to a list
assigned_ous_groups.append(temp_dict)
#Much the same is done for groups, except we first have to go through the OUs, like with the initial authentication server
if ou['name'] in parent_ous:
ou_dn_urlencoded = urllib.parse.quote(ou['dn'], safe="")
groups_live_navigation_url = f'{api_url_prefix}/idps/{idp_id}/live_navigate?dn={ou_dn_urlencoded}'
response = session.get(url=groups_live_navigation_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for group in response_decoded['data']:
if group['name'] in assigned_group_names:
temp_dict = {"guid": group['guid'], "name": group['name'], "dn": group['dn'], "path": group['canonical_name'], }
assigned_ous_groups.append(temp_dict)
#With all the dictionaries in the list we can assemble the JSON payload
domain_data = {
"sync_mins":60,
"is_imported": True,
"selected_group_containers": assigned_ous_groups
}
#Create domain import
session.patch(url=domain_import_url, json=domain_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
I am only focusing on these two types of profiles because they are probably the most used ones, and going through all of them would take too long. If you want to know how to interact with the other types, you can reverse engineer them.
Both profile data variables contain most, if not all, options you can set. You can omit options if you want the defaults (most options are already the defaults).
When it comes to updating things in EMS, you have to be careful because you often cannot just do a PATCH. Most endpoints for updating use a PUT, and when you do that, you are only setting the options you supply, and for everything else, the defaults are used.
In the example script, I disable security postage tags on the FortiClient GUI and disable bubble notifications in the system settings profile. If I were to send just this to the /api/v1/profiles/system/{ID}/update endpoint, it would set these two things, but it would set everything else to the default values. I handle this case by first performing a GET on the existing profile, saving the information in a variable and performing a deep merge with the updated values. The resulting variable, including the old and updated information, then gets used for the PUT.
This update procedure is also where the deep_merge function is used, which Claude has written.
Update system and remote access profile using deep merge
'''
ems_update_profiles_deep_merge.py
Update a system and a remote access VPN profile by first importing the existing configuration using the FortiClient EMS API
This script updates the system settings profile created in ems_create_profiles.py by disabling security posting tags on the GUI and disabling bubble notifications
This script updates the VPN profile created in ems_create_profiles.py by
* Enabling the save username option
* Changing the remote gateway
* Changing the phase 1 DH group
* Enabling session resume
* Changing the phase 2 proposals
* Disabling personal VPNs
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
def deep_merge(base, override):
"""
Function written by Claude Sonnet 4.6
Recursively merge `override` into `base`.
- Dicts: merged recursively.
- Lists of dicts with a 'name' key: matched by name, then merged recursively.
- Everything else: override replaces base.
"""
if isinstance(base, dict) and isinstance(override, dict):
result = base.copy()
for key, override_val in override.items():
base_val = result.get(key)
result[key] = deep_merge(base_val, override_val)
return result
if (
isinstance(base, list)
and isinstance(override, list)
and all(isinstance(i, dict) and "name" in i for i in base + override)
):
# Match connection objects by 'name', merge matched pairs
base_by_name = {item["name"]: item for item in base}
result = []
for override_item in override:
name = override_item["name"]
if name in base_by_name:
result.append(deep_merge(base_by_name[name], override_item))
else:
result.append(override_item) # new entry, add as-is
# Preserve base entries not present in override
override_names = {item["name"] for item in override}
for base_item in base:
if base_item["name"] not in override_names:
result.append(base_item)
return result
# Scalar, plain list, or mismatched types: override wins
return override
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
temp_password = 'Start123$'
system_profile_name = "SYS_EMS-API"
vpn_profile_name = "VPN_EMS-API"
vpn_ipsec_connection_name = "API-IPSEC-VPN"
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
system_profiles_get_url = f'{api_url_prefix}/profiles/system/index'
vpn_profiles_get_url = f'{api_url_prefix}/profiles/vpn/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
updated_system_profile_data = {
"json": {
"system": {
"ui": {
"show_host_tag": 0,
}
},
"endpoint_control": {
"show_bubble_notifications": 0,
}
}
}
updated_vpn_profile_data = {
"name": f"{vpn_profile_name}",
"json": {
"vpn": {
"ipsecvpn": {
"connections": [
{
"name": f"{vpn_ipsec_connection_name}",
"ui": {
"save_username": 1
},
"ike_settings": {
"server": "192.0.2.254",
"dhgroup": [21],
"session_resume": 1,
},
"ipsec_settings": {
"proposals": [
{
"encryption": "AES256GCM",
"authentication": "NONE"
},
{
"encryption": "AES256",
"authentication": "SHA512"
}
]
},
}
]
},
"options": {
"allow_personal_vpns": 0,
}
}
}
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get system profiles
response = session.get(url=system_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == system_profile_name:
system_profile_get_url = f'{api_url_prefix}/profiles/system/{profile["id"]}/get'
response = session.get(url=system_profile_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
system_profile_data = response_decoded['data']
#Update system_profile_data with the updated information
system_profile_data = deep_merge(system_profile_data, updated_system_profile_data)
#Set the correct URL for updating the system profile using the profile ID
system_profile_update_url = f'{api_url_prefix}/profiles/system/{profile["id"]}/update'
#Update system profile
session.put(url=system_profile_update_url, json=system_profile_data, headers=change_headers, verify=False, timeout=30)
#Get VPN profiles
response = session.get(url=vpn_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == vpn_profile_name:
vpn_profile_get_url = f'{api_url_prefix}/profiles/vpn/{profile["id"]}/get'
response = session.get(url=vpn_profile_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
vpn_profile_data = response_decoded['data']
#Update vpn_profile_data with the updated information
#update_dictionary(vpn_profile_data['json']['vpn'], updated_vpn_profile_data['json']['vpn'])
vpn_profile_data = deep_merge(vpn_profile_data, updated_vpn_profile_data)
#Set the correct URL for updating the VPN profile using the profile ID
vpn_profile_update_url = f'{api_url_prefix}/profiles/vpn/{profile["id"]}/update'
#Update VPN profile
session.put(url=vpn_profile_update_url, json=vpn_profile_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Update system and remote access profile using deep merge response
In the GitHub repository, there is also a script called ems_update_profiles_entire_data.py, which has the entire data as a variable and sends it without first copying the existing profile. This is an alternative and is almost the same as creating a profile, except it is still an update.
Creating security posture/ZTNA tags and rules
This is the one that, I believe, is impossible to do with the official documentation, because the documentation, when it comes to rules, just says what type of values (string, integer or boolean) you have to supply, but it is not possible to know what any value should actually be. Reverse engineering luckily solves this problem.
If you want to employ custom logic for rules, good luck with only the documentation, because all you get is string for what you need to supply.
Both these cases are covered in the script, and there is some additional information in the comments regarding a few keys you use in rules.
Create security posture/ZTNA tag and rules
'''
ems_create_ztna_tag_rule.py
Create a ZTNA tag and associated rule using the FortiClient EMS API
'''
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
ztna_url = f'{api_url_prefix}/tags/zero_trust/create'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#fct_based means the rule is evaluated on FortiClient directly, not on EMS
#The id value in the rules key must be incremented for each rule of a tag
#The type should be the position of the option in the GUI, meaning that the first element of the dropdown is 1, the second 2, etc.
#Not all types where checked if this rule of numbering holds true and this can change in the future
#The os number is in the order in the GUI from left to right, starting at 1 for Windows
#The content is completely dependent on the type of rule used, but most often corresponds to whatever you would enter in a field or what you can select
ztna_data = {
"name":"ZTNA-TAG_API",
"description":"This is the User Notification Message",
"status": True,
"comments":"Created using the API",
"rules":[
{
"negative": False,
"content":"GROUPS/ZTNA_USERS",
"domainName":"ad.labdomain.com",
"type":1,
"os":1,
"id":1,
"fct_based":True
},
{
"negative": False,
"content":"C:\\temp\\file1.txt",
"context":"",
"type":4,
"os":1,
"id":2,
}
],
"use_custom_logic": False
}
ztna_data_custom_logic = {
"name":"ZTNA-TAG-CUSTOM-LOGIC_API",
"description":"This is the User Notification Message",
"status": True,
"comments":"Created using the API with custom logic",
"rules":[
{
"negative": False,
"content":"GROUPS/ZTNA_USERS",
"domainName":"ad.labdomain.com",
"type":1,
"os":1,
"id":1,
"fct_based":True
},
{
"negative": False,
"content":"C:\\temp\\file1.txt",
"context":"",
"type":4,
"os":1,
"id":2,
}
],
"use_custom_logic": True,
"logic":{
"android": None,
"ios": None,
"linux": None,
"mac": None,
"windows":"{\"op\":\"or\",\"rules\":[{\"id\":1},{\"id\":2}]}"
}
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Create ZTNA tag and rule
session.post(url=ztna_url, json=ztna_data, headers=change_headers, verify=False, timeout=30)
#Create ZTNA tag and rule with custom logic
session.post(url=ztna_url, json=ztna_data_custom_logic, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Create security posture/ZTNA tag and rules response
{
"result":{
"retval":1,
"message":"Tag 'ZTNA-TAG_API' created successfully."
},
"data":{
"id":81,
"tag_uid":"8fa2287f-9c7e-43aa-a792-6cd32b16013f",
"name":"ZTNA-TAG_API",
"type":"zero_trust",
"type_id":1,
"detection_level":null,
"description":"This is the User Notification Message",
"update_time":"2026-06-28T14:24:12.973"
}
},
{
"result":{
"retval":1,
"message":"Tag 'ZTNA-TAG-CUSTOM-LOGIC_API' created successfully."
},
"data":{
"id":84,
"tag_uid":"53cf2c7d-32d7-4e6a-96d6-8ddc228dc742",
"name":"ZTNA-TAG-CUSTOM-LOGIC_API",
"type":"zero_trust",
"type_id":1,
"detection_level":null,
"description":"This is the User Notification Message",
"update_time":"2026-06-28T14:24:13.025"
}
}
Updating security posture/ZTNA tags and rules
If you want to update a tag and rule, you should just send the entire data to the /api/v1/tags/zero_trust/update_one endpoint as a POST, as an easy way. The problem is that a GET on /api/v1/tags/zero_trust/{ID}/get gives you different information from what the POST requires, so you’d first have to format the GET response, perform a deep merge with your new information and then send a POST. Maybe there is a better solution, but I can’t say I found one.
Update security posture/ZTNA tag and rules
'''
ems_update_ztna_tag_rule.py
Update a ZTNA tag and associated rule using the FortiClient EMS API
This updates the first rule to target VPN_USERS (from ZTNA_USERS) and the second rule to search for file2.txt (from file1.txt)
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
ztna_tag_name = 'ZTNA-TAG_API'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
ztna_get_url = f'{api_url_prefix}/tags/zero_trust/index'
ztna_update_url = f'{api_url_prefix}/tags/zero_trust/update_one'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#fct_based means the rule is evaluated on FortiClient directly, not on EMS
#The id value in the rules key must be incremented for each rule of a tag
#The type should be the position of the option in the GUI, meaning that the first element of the dropdown is 1, the second 2, etc.
#Not all types where checked if this rule of numbering holds true and this can change in the future
#The os number is in the order in the GUI from left to right, starting at 1 for Windows
#The content is completely dependent on the type of rule used, but most often corresponds to whatever you would enter in a field or what you can select
updated_ztna_data = {
"name":"ZTNA-TAG_API",
"description":"This is the User Notification Message",
"status": True,
"comments":"Created using the API",
"rules":[
{
"negative": False,
"content":"GROUPS/VPN_USERS",
"domainName":"ad.labdomain.com",
"type":1,
"os":1,
"id":1,
"fct_based":True
},
{
"negative": False,
"content":"C:\\temp\\file2.txt",
"context":"",
"type":4,
"os":1,
"id":2,
}
],
"use_custom_logic": False
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get ZTNA tag information
response = session.get(url=ztna_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for ztna_tag in response_decoded['data']['tags']:
if ztna_tag['name'] == ztna_tag_name:
updated_ztna_data['id'] = ztna_tag['id']
session.post(url=ztna_update_url, json=updated_ztna_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Update security posture/ZTNA tag and rules response
tag_id instead of id, the logic parts are completely different, and you have additional key-value pairs. It’s not impossible to do a nice update, but I didn’t create such a function for this post.
Create an on-fabric detection rule
This API endpoint is completely missing from the documentation, by the way.
A funny thing about the Local IP/Subnet type. In the GUI, you are restricted in what you can enter, and you can only enter private IP ranges. If you use the API, you can set any and all subnets and IPs. You cannot edit the specific rule after this in the GUI, however.
Create on-fabric detection rule
'''
ems_create_on_net_rule.py
Create on-net rule using the FortiClient EMS API
'''
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
on_net_url = f'{api_url_prefix}/on_net_rules/create'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
on_net_data = {
"name":"ON-NET_API",
"enabled": True,
"comments":"On-net rule created via the API",
"local_ip":"192.0.2.0/24",
"dns_server_ip":"198.51.100.1",
"public_ip":"203.0.113.1"
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Create on-net rule
session.post(url=on_net_url, json=on_net_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Much like with the ZTNA tag, the GET response is different from what you need for an update, so while /api/v1/on_net_rules/{ID}/update exists as an endpoint with a PATCH method, it is easier to send the data for the entire rule again.
Update on-fabric detection rule
'''
ems_update_on_net_rule.py
Update on-net rule using the FortiClient EMS API
This updates the on-fabric rule with a new DNS server and public IP
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
on_net_rule_name = "ON-NET_API"
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
on_net_get_url = f'{api_url_prefix}/on_net_rules/index'
on_net_update_url = f'{api_url_prefix}/on_net_rules/5/update'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
updated_on_net_data = {
"name":f"{on_net_rule_name}",
"enabled": True,
"comments":"On-net rule create via the API",
"local_ip":"192.0.2.0/24",
"dns_server_ip":"198.51.100.100",
"public_ip":"203.0.113.100"
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get on-net rules information
response = session.get(url=on_net_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get ID of on-net rule, set URL accordingly and update rule
for rule in response_decoded['data']['rule_sets']:
if rule['name'] == on_net_rule_name:
on_net_update_url = f'{api_url_prefix}/on_net_rules/{rule['id']}/update'
session.patch(url=on_net_update_url, json=updated_on_net_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Update on-fabric detection rule
'''
ems_update_on_net_rule.py
Update on-net rule using the FortiClient EMS API
This updates the on-fabric rule with a new DNS server and public IP
'''
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
on_net_url = f'{api_url_prefix}/on_net_rules/5/update'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
updated_on_net_data = {
"name":"ON-NET_API",
"enabled": True,
"comments":"On-net rule create via the API",
"local_ip":"192.0.2.0/24",
"dns_server_ip":"198.51.100.100",
"public_ip":"203.0.113.100"
}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Update on-net rule
session.patch(url=on_net_url, json=updated_on_net_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Compare the data of the updated_on_net_data variable with the data from a GET request on the rule, and you, again, see the problem.
We have a completely different structure here. Maybe there is a better solution, but I don’t know it.
Creating a policy
This is probably the most complicated piece that I cover, because a lot goes into a policy.
You need:
The assignment of imported OUs and groups, which is only a bit less difficult than the domain import
Get the required on-fabric detection rule ID
Get the IDs of each profile you want to assign
This makes this script the one I spent the most time on, but it works.
Create policy
'''
ems_create_policy.py
Create a policy with profiles for AD OUs and groups and an on-net rule using the FortiClient EMS API
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
idp_name = "ad.labdomain.com"
#Adding groups and OUs to a policy is a bit complicated, so here is an example.
#Consider the following AD structure:
#ad.labdomain.com
#└── CLIENTS
#└── GROUPS
# ├── VPN_USERS
# ├── ZTNA_USERS
#└── SERVERS
# └── PROD
#If you want to assign the entire CLIENTS OU to a policy add the name to the assigned_ous list
#The script will get the ID of the OU and add it to the list of IDs that should get added to the policy
#If you want to add the VPN_USERS group to a policy and using the structure from above you first have to get the GUID of the GROUPS OU
#With the GUID you can then look up all the groups in that OU and get the ID for the group and add that to the list of IDs that should get added to the policy
#In order to facilitate this add the parent OU to the parent_ous list and the groups you want to assign, that are in the parent OU, to the assigned_group_names list
assigned_ous = ['CLIENTS']
parent_ous = ['GROUPS', 'SERVERS']
assigned_group_names = ['PROD', 'VPN_USERS']
on_net_rule_name = "ON-NET_API"
on_net_rule_id = 0
ou_group_ids = []
#You just need to supply the name for the desired profile
on_net_profiles = {
"vpn": {'name':'Default'},
"ztna": {'name':'Default'},
"webfilter": {'name':'Default'},
"videofilter": {'name':'Default'},
"vulnerability_scan": {'name':'Default'},
"malware": {'name':'Default'},
"sandbox": {'name':'Default'},
"firewall": {'name':'Default'},
"ftdata_scan": {'name':'Default'},
"system": {'name':'SYS_EMS-API'}
}
off_net_profiles = {
"vpn": {'name':'VPN_EMS-API'},
"ztna": {'name':'Default'},
"webfilter": {'name':'Default'},
"videofilter": {'name':'Default'},
"vulnerability_scan": {'name':'Default'},
"malware": {'name':'Default'},
"sandbox": {'name':'Default'},
"firewall": {'name':'Default'},
"ftdata_scan": {'name':'Default'},
"system": {'name':'SYS_EMS-API'}
}
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
policy_url = f'{api_url_prefix}/endpoint_policies/create'
idps_url = f'{api_url_prefix}/idps/index'
on_net_url = f'{api_url_prefix}/on_net_rules/index'
#This list is used later to loop through so we don't need to reuse code
profile_type_names = ['vpn', 'ztna','webfilter','videofilter','vulnerability_scan','malware','sandbox','firewall','ftdata_scan','system']
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get authentication servers data
response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get imported OUs from IDPS and add the IDs to the ou_group_ids list
for idp in response_decoded['data']:
if idp['domain_info']['name'] == idp_name:
idp_id = idp['domain_info']['guid']
idp_groups_url = f'{api_url_prefix}/idps/adfs/{idp_id}/imported_ous'
response = session.get(url=idp_groups_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#The two ifs are used to search for the OU name in the assigned OUs, if we want to assign an entire OU to a policy and search through the parent OUs if we need to assign groups in OUs
for ou in response_decoded['data']['group_containers']:
if ou['name'] in assigned_ous or ou['name'] in assigned_group_names:
ou_group_ids.append(ou['id'])
if ou['name'] in parent_ous:
ou_groups_url = f'{api_url_prefix}/idps/adfs/{ou['guid']}/imported_ous'
response = session.get(url=ou_groups_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for group in response_decoded['data']['group_containers']:
if group['name'] in assigned_group_names:
ou_group_ids.append(group['id'])
#We create a list that will hold dictionaries with each ID that should be assigned to the policy
endpoint_group_ids = []
for id_entry in ou_group_ids:
id_dict = {"id":id_entry}
endpoint_group_ids.append(id_dict)
#Get on-net rules and set ID based on given name
response = session.get(url=on_net_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for rule in response_decoded['data']['rule_sets']:
if rule['name'] == on_net_rule_name:
on_net_rule_id = rule['id']
#Loop through all profile types and add the ID for each named profile to the dictionary
for profile_type in profile_type_names:
profile_url = f'{api_url_prefix}/profiles/{profile_type}/index'
response = session.get(url=profile_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == on_net_profiles[f'{profile_type}']['name']:
on_net_profiles[f'{profile_type}']['id'] = profile['id']
if profile['name'] == off_net_profiles[f'{profile_type}']['name']:
off_net_profiles[f'{profile_type}']['id'] = profile['id']
policy_data = {
"name":"POLICY_API",
"endpoint_groups":endpoint_group_ids,
"enable_on_off_net":True,
"profile_components":{
"vpn":{
"id":on_net_profiles['vpn']['id'],
},
"ztna":{
"id":on_net_profiles['ztna']['id'],
},
"webfilter":{
"id":on_net_profiles['webfilter']['id'],
},
"videofilter":{
"id":on_net_profiles['videofilter']['id'],
},
"vulnerability_scan":{
"id":on_net_profiles['vulnerability_scan']['id'],
},
"malware":{
"id":on_net_profiles['malware']['id'],
},
"sandbox":{
"id":on_net_profiles['sandbox']['id'],
},
"firewall":{
"id":on_net_profiles['firewall']['id'],
},
"ftdata_scan":{
"id":on_net_profiles['ftdata_scan']['id'],
},
"system":{
"id":on_net_profiles['system']['id'],
}
},
"off_net_profile_components":{
"vpn":{
"id":off_net_profiles['vpn']['id'],
},
"ztna":{
"id":off_net_profiles['ztna']['id'],
},
"webfilter":{
"id":off_net_profiles['webfilter']['id'],
},
"videofilter":{
"id":off_net_profiles['videofilter']['id'],
},
"vulnerability_scan":{
"id":off_net_profiles['vulnerability_scan']['id'],
},
"malware":{
"id":off_net_profiles['malware']['id'],
},
"sandbox":{
"id":off_net_profiles['sandbox']['id'],
},
"firewall":{
"id":off_net_profiles['firewall']['id'],
},
"ftdata_scan":{
"id":off_net_profiles['ftdata_scan']['id'],
},
"system":{
"id":off_net_profiles['system']['id'],
}
},
"telemetry_server_list":None,
"on_net_rules":[
{
"id":on_net_rule_id,
}
],
"comments":"Policy created using the API",
"enabled":True,
}
#Create policy
session.post(url=policy_url, json=policy_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Third time’s the charm, and updating a policy has the same problem as ZTNA tags and on-fabric detection rules. The GET response is different from what you need, so just send the entire data again for an update instead of updating just the desired components.
Update policy
'''
ems_update_policy.py
Update a policy with profiles for AD OUs and groups and an on-net rule using the FortiClient EMS API
This script updates the policy by setting different groups (from VPN_USERS to ZTNA_USERS) and a different VPN profile in the off-net profile components (from VPN_EMS-API to Default)
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
idp_name = "ad.labdomain.com"
policy_name = "POLICY_API"
#Adding groups and OUs to a policy is a bit complicated, so here is an example.
#Consider the following AD structure:
#ad.labdomain.com
#└── CLIENTS
#└── GROUPS
# ├── VPN_USERS
# ├── ZTNA_USERS
#└── SERVERS
# └── PROD
#If you want to assign the entire CLIENTS OU to a policy add the name to the assigned_ous list
#The script will get the ID of the OU and add it to the list of IDs that should get added to the policy
#If you want to add the VPN_USERS group to a policy and using the structure from above you first have to get the GUID of the GROUPS OU
#With the GUID you can then look up all the groups in that OU and get the ID for the group and add that to the list of IDs that should get added to the policy
#In order to facilitate this add the parent OU to the parent_ous list and the groups you want to assign, that are in the parent OU, to the assigned_group_names list
assigned_ous = ["CLIENTS"]
parent_ous = ['GROUPS', 'SERVERS']
assigned_group_names = ['PROD', 'ZTNA_USERS']
on_net_rule_name = "ON-NET_API"
ou_group_ids = []
on_net_rule_id = 0
#You just need to supply the name for the desired profile
on_net_profiles = {
"vpn": {'name':'Default'},
"ztna": {'name':'Default'},
"webfilter": {'name':'Default'},
"videofilter": {'name':'Default'},
"vulnerability_scan": {'name':'Default'},
"malware": {'name':'Default'},
"sandbox": {'name':'Default'},
"firewall": {'name':'Default'},
"ftdata_scan": {'name':'Default'},
"system": {'name':'SYS_EMS-API'}
}
off_net_profiles = {
"vpn": {'name':'Default'},
"ztna": {'name':'Default'},
"webfilter": {'name':'Default'},
"videofilter": {'name':'Default'},
"vulnerability_scan": {'name':'Default'},
"malware": {'name':'Default'},
"sandbox": {'name':'Default'},
"firewall": {'name':'Default'},
"ftdata_scan": {'name':'Default'},
"system": {'name':'SYS_EMS-API'}
}
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
policy_get_url = f'{api_url_prefix}/endpoint_policies/index'
idps_url = f'{api_url_prefix}/idps/index'
on_net_url = f'{api_url_prefix}/on_net_rules/index'
#This list is used later to loop through so we don't need to reuse code
profile_type_names = ['vpn', 'ztna','webfilter','videofilter','vulnerability_scan','malware','sandbox','firewall','ftdata_scan','system']
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get authentication servers data
response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get imported OUs from IDPS and add the IDs to the ou_group_ids list
for idp in response_decoded['data']:
if idp['domain_info']['name'] == idp_name:
idp_id = idp['domain_info']['guid']
idp_groups_url = f'{api_url_prefix}/idps/adfs/{idp_id}/imported_ous'
response = session.get(url=idp_groups_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#The two ifs are used to search for the OU name in the assigned OUs, if we want to assign an entire OU to a policy and search through the parent OUs if we need to assign groups in OUs
for ou in response_decoded['data']['group_containers']:
if ou['name'] in assigned_ous or ou['name'] in assigned_group_names:
ou_group_ids.append(ou['id'])
if ou['name'] in parent_ous:
ou_groups_url = f'{api_url_prefix}/idps/adfs/{ou['guid']}/imported_ous'
response = session.get(url=ou_groups_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for group in response_decoded['data']['group_containers']:
if group['name'] in assigned_group_names:
ou_group_ids.append(group['id'])
#We create a list that will hold dictionaries with each ID that should be assigned to the policy
endpoint_group_ids = []
for id_entry in ou_group_ids:
id_dict = {"id":id_entry}
endpoint_group_ids.append(id_dict)
#Get on-net rules and set ID based on given name
response = session.get(url=on_net_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for rule in response_decoded['data']['rule_sets']:
if rule['name'] == on_net_rule_name:
on_net_rule_id = rule['id']
#Loop through all profile types and add the ID for each named profile to the dictionary
for profile_type in profile_type_names:
profile_url = f'{api_url_prefix}/profiles/{profile_type}/index'
response = session.get(url=profile_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == on_net_profiles[f'{profile_type}']['name']:
on_net_profiles[f'{profile_type}']['id'] = profile['id']
if profile['name'] == off_net_profiles[f'{profile_type}']['name']:
off_net_profiles[f'{profile_type}']['id'] = profile['id']
updated_policy_data = {
"name":"POLICY_API",
"endpoint_groups":endpoint_group_ids,
"enable_on_off_net":True,
"profile_components":{
"vpn":{
"id":on_net_profiles['vpn']['id'],
},
"ztna":{
"id":on_net_profiles['ztna']['id'],
},
"webfilter":{
"id":on_net_profiles['webfilter']['id'],
},
"videofilter":{
"id":on_net_profiles['videofilter']['id'],
},
"vulnerability_scan":{
"id":on_net_profiles['vulnerability_scan']['id'],
},
"malware":{
"id":on_net_profiles['malware']['id'],
},
"sandbox":{
"id":on_net_profiles['sandbox']['id'],
},
"firewall":{
"id":on_net_profiles['firewall']['id'],
},
"ftdata_scan":{
"id":on_net_profiles['ftdata_scan']['id'],
},
"system":{
"id":on_net_profiles['system']['id'],
}
},
"off_net_profile_components":{
"vpn":{
"id":off_net_profiles['vpn']['id'],
},
"ztna":{
"id":off_net_profiles['ztna']['id'],
},
"webfilter":{
"id":off_net_profiles['webfilter']['id'],
},
"videofilter":{
"id":off_net_profiles['videofilter']['id'],
},
"vulnerability_scan":{
"id":off_net_profiles['vulnerability_scan']['id'],
},
"malware":{
"id":off_net_profiles['malware']['id'],
},
"sandbox":{
"id":off_net_profiles['sandbox']['id'],
},
"firewall":{
"id":off_net_profiles['firewall']['id'],
},
"ftdata_scan":{
"id":off_net_profiles['ftdata_scan']['id'],
},
"system":{
"id":off_net_profiles['system']['id'],
}
},
"telemetry_server_list":None,
"on_net_rules":[
{
"id":on_net_rule_id,
}
],
"comments":"Test",
"enabled":True,
}
#Get policy ID, set patch URL and update policy
response = session.get(url=policy_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for policy in response_decoded['data']:
if policy['name'] == policy_name:
policy_update_url = f'{api_url_prefix}/endpoint_policies/{policy['id']}/update'
#Update policy
session.patch(url=policy_update_url, json=updated_policy_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
groups instead of endpoint_groups in a different format and rule_sets instead of on_net_rules, again, in a different format.
Creating an installer
All installer API endpoints are missing from the documentation.
Creating an installer isn’t that difficult, and most options are self-explanatory, but you have to keep the three variables for the version and the features in mind when working with it.
Note: Feature 15, EDR, is only available in Cloud EMS. If you have an on-prem EMS, like me, you have to remove this feature. I have commented it out in my script. Also, installer names are kept track of internally, so you cannot create installers with the same name, even if you delete one. Maybe there is some cleanup on EMS upgrades or periodically, however.
Create installer
'''
ems_create_installer.py
Create FortiClient installer using the FortiClient EMS API
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
version_major_minor = "7.4"
installer_name = "7.4.7"
fct_comparable = 7004007
system_profile_name = "SYS_EMS-API"
system_profile_id = 0
vpn_profile_name = "VPN_EMS-API"
vpn_profile_id = 0
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
installer_url = f'{api_url_prefix}/assignable_installers/create'
system_profiles_get_url = f'{api_url_prefix}/profiles/system/index'
vpn_profiles_get_url = f'{api_url_prefix}/profiles/vpn/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get system profiles
response = session.get(url=system_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == system_profile_name:
system_profile_id = {profile["id"]}
#The id is a set, so we convert it to a list and get the only element from it
system_profile_id = list(system_profile_id)[0]
#Get VPN profiles
response = session.get(url=vpn_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == vpn_profile_name:
vpn_profile_id = {profile["id"]}
#The id is a set, so we convert it to a list and get the only element from it
vpn_profile_id = list(vpn_profile_id)[0]
installer_data = {
"name":"API-INSTALLER",
"notes":"FortiClient installer created via API",
"version_major_minor":version_major_minor,
"auto_update":None,
"installer_name":installer_name,
"fct_comparable":fct_comparable,
"windows_installer":True,
"mac_installer":True,
"linux_installer":True,
"windows_arm_installer":False,
"linux_arm_installer":False ,
"features":[
5, #Zero Trust Telemetry
3, #Secure Access Architecture Components
7, #Vulnerability Scan
6, #Advanced Persistent Threat (APT) Components
1, #AntiVirus, Anti-Exploit, Removable Media Access
10, #Anti-Ransomware
9, #Cloud Based Malware Outbreak Detection
2, #Web and Video Filtering
4, #Application Firewall
8, #Single Sign-On Mobility Agent
11, #Zero Trust Network Access
13, #Privileged Access Agent
16, #Data Protection
12#, #FIPS Certification
#15, #EDR, only in cloud EMS, remove if on-prem EMS
],
"auto_register":True,
"desktop_shortcut":True,
"start_menu_shortcut":False,
"msi_files":True,
"override_invitation_code":False,
"group_assignment_rules_id":None,
"vpn_profile_component_id":vpn_profile_id,
"system_profile_component_id":system_profile_id,
"invalid_cert_action":None,
"telemetry_server_list_id":None
}
#Create installer
response = session.post(url=installer_url, json=installer_data, headers=change_headers, verify=False, timeout=30)
response_decoded = response.content.decode('utf-8')
print(response_decoded)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Create installer response
{
"result":{
"retval":1,
"message":null
}
}
Updating an installer
Like always at this point, the GET is different from what you need, so send the entire data as your payload again.
Update installer
'''
ems_update_installer.py
Update FortiClient installer using the FortiClient EMS API
This updates the existing installer by:
* Setting Mac and Linux installers to False
* Removing the Data Protection and FIPS Certification features
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
installer_real_name = "API-INSTALLER"
version_major_minor = "7.4"
installer_name = "7.4.7"
fct_comparable = 7004007
system_profile_name = "SYS_EMS-API"
system_profile_id = 0
vpn_profile_name = "VPN_EMS-API"
vpn_profile_id = 0
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
installer_index_url = f'{api_url_prefix}/assignable_installers/index'
system_profiles_get_url = f'{api_url_prefix}/profiles/system/index'
vpn_profiles_get_url = f'{api_url_prefix}/profiles/vpn/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get system profiles
response = session.get(url=system_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == system_profile_name:
system_profile_id = {profile["id"]}
#The id is a set, so we convert it to a list and get the only element from it
system_profile_id = list(system_profile_id)[0]
#Get VPN profiles
response = session.get(url=vpn_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == vpn_profile_name:
vpn_profile_id = {profile["id"]}
#The id is a set, so we convert it to a list and get the only element from it
vpn_profile_id = list(vpn_profile_id)[0]
updated_installer_data = {
"name":"API-INSTALLER",
"notes":"FortiClient installer updated via API",
"version_major_minor":version_major_minor,
"auto_update":None,
"installer_name":installer_name,
"fct_comparable":fct_comparable,
"windows_installer":True,
"mac_installer":False,
"linux_installer":False,
"windows_arm_installer":False,
"linux_arm_installer":False ,
"features":[
5, #Zero Trust Telemetry
3, #Secure Access Architecture Components
7, #Vulnerability Scan
6, #Advanced Persistent Threat (APT) Components
1, #AntiVirus, Anti-Exploit, Removable Media Access
10, #Anti-Ransomware
9, #Cloud Based Malware Outbreak Detection
2, #Web and Video Filtering
4, #Application Firewall
8, #Single Sign-On Mobility Agent
11, #Zero Trust Network Access
13, #Privileged Access Agent
],
"auto_register":True,
"desktop_shortcut":True,
"start_menu_shortcut":False,
"msi_files":True,
"override_invitation_code":False,
"group_assignment_rules_id":None,
"vpn_profile_component_id":vpn_profile_id,
"system_profile_component_id":system_profile_id,
"invalid_cert_action":None,
"telemetry_server_list_id":None
}
#Get installer ID
response = session.get(url=installer_index_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for installers in response_decoded['data']['installers']:
if installers['name'] == installer_real_name:
installer_id = {installers["id"]}
#The id is a set, so we convert it to a list and get the only element from it
installer_id = list(installer_id)[0]
#Assemble the URLs with the ID
installer_update_url = f'{api_url_prefix}/assignable_installers/{installer_id}/update'
#Update installer
session.patch(url=installer_update_url, json=updated_installer_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Update installer response
{
"result":{
"retval":1,
"message":null
}
}
If we look at the GET, we can see that there is a lot more information in an installer, like the entire VPN and system settings profile information, if you have attached one.
If you work with Cloud EMS, note that the API endpoints use /cloud/invitations instead of the on-prem /api/v1/invitation
Invitations are relatively straightforward, and the only thing of note is that if you want to have an installer attached, you need to create it with the invitation. You cannot create an invitation and later attach an installer.
Create invitation
'''
ems_create_invitation.py
Create invitation with domain verification and installer using the FortiClient EMS API
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
version_major_minor = "7.4"
installer_name = "7.4.7"
fct_comparable = 7004007
system_profile_name = "SYS_EMS-API"
system_profile_id = 0
vpn_profile_name = "VPN_EMS-API"
vpn_profile_id = 0
idp_name = "ad.labdomain.com"
idp_id = 0
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
invitation_url = f'{api_url_prefix}/invitation/create'
system_profiles_get_url = f'{api_url_prefix}/profiles/system/index'
vpn_profiles_get_url = f'{api_url_prefix}/profiles/vpn/index'
idps_url = f'{api_url_prefix}/idps/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get system profiles
response = session.get(url=system_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == system_profile_name:
system_profile_id = {profile["id"]}
#The id is a set, so we convert it to a list and get the only element from it
system_profile_id = list(system_profile_id)[0]
#Get VPN profiles
response = session.get(url=vpn_profiles_get_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
for profile in response_decoded['data']['local']:
if profile['name'] == vpn_profile_name:
vpn_profile_id = {profile["id"]}
#The id is a set, so we convert it to a list and get the only element from it
vpn_profile_id = list(vpn_profile_id)[0]
#Get authentication servers data
response = session.get(url=idps_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get all the necessary information directly from the IDP and create the data for the domain import
for idp in response_decoded['data']:
if idp['domain_info']['name'] == idp_name:
idp_id = idp['domain_info']['guid']
#Use None for expiry_date to have no expiration
#The mapping for the authentication type is 0=None, 1=Local, 2=Domain, 3=SAML
invitation_data = {
"name":"API-INVITATION-INSTALLER",
"comments":"Invitation created via API",
"has_email_notifications":False,
"expiry_date":"2026-12-31",
"authentication_type":2,
"email_template_id":1,
"is_bulk":True,
"listen_address":f"{ems_server}:8013",
"assignable_installer":{
"name":"INVITATION-INSTALLER",
"notes":"FortiClient invitation installer created via API",
"version_major_minor":version_major_minor,
"auto_update":None,
"installer_name":installer_name,
"fct_comparable":fct_comparable,
"windows_installer":True,
"mac_installer":True,
"linux_installer":True,
"windows_arm_installer":False,
"linux_arm_installer":False ,
"features":[
5, #Zero Trust Telemetry
3, #Secure Access Architecture Components
7, #Vulnerability Scan
6, #Advanced Persistent Threat (APT) Components
1, #AntiVirus, Anti-Exploit, Removable Media Access
10, #Anti-Ransomware
9, #Cloud Based Malware Outbreak Detection
2, #Web and Video Filtering
4, #Application Firewall
8, #Single Sign-On Mobility Agent
11, #Zero Trust Network Access
13, #Privileged Access Agent
16, #Data Protection
12#, #FIPS Certification
#15, #EDR, only in cloud EMS, remove if on-prem EMS
],
"auto_register":True,
"desktop_shortcut":True,
"start_menu_shortcut":False,
"msi_files":True,
"override_invitation_code":False,
"group_assignment_rules_id":None,
"vpn_profile_component_id":vpn_profile_id,
"system_profile_component_id":system_profile_id,
"invalid_cert_action":None,
"telemetry_server_list_id":None
},
"domain_guid":idp_id,
"user_id":None,
"saml_config_id":None
}
#Create invitation
session.post(url=invitation_url, json=invitation_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
This process is pointless. Apparently, you can only change the name, EMS listen address, and comment of an existing invitation, even if you do update it. You can send more information, like changing the Verification Type, but it doesn’t actually update it. There is an update script in the repository, but I don’t bother posting one here.
Getting endpoint data and matching on the endpoint name
Probably the most fundamental API call and it’s almost at the end.
The script gets all endpoint data using the API and performs a match on a specific endpoint using the endpoint’s name.
GET endpoint data and match on endpoint name
'''
ems_get_endpoint_data.py
Get endpoint data and ID for named endpoint using the FortiClient EMS API
'''
import json
import requests
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Disable warnings
requests.urllib3.disable_warnings()
#Set some variables for the API
ems_server = '192.168.1.208'
endpoint_name = "WIN11-CLIENT"
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
endpoints_url = f'{api_url_prefix}/endpoints/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get endpoint data
response = session.get(url=endpoints_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
print(response_decoded)
#Get endpoint ID with name match
for endpoint in response_decoded['data']['endpoints']:
if endpoint['name'] == endpoint_name:
print(f"Endpoint ID for {endpoint_name}: {endpoint['device_id']}")
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
GET endpoint data and match on endpoint name response
If you want to get all endpoints where a user is recorded as the last seen user, you can use this script.
GET endpoint of named user
'''
ems_get_named_user_endpoint.py
Get endpoint for named user using the FortiClient EMS API
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
user_name = "labuser"
user_id = [] #This is a list, because a user can be associated with multiple endpoints
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
endpoints_url = f'{api_url_prefix}/endpoints/index'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
#Get endpoint data
response = session.get(url=endpoints_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get user FortiClient ID with name match, then get endpoint ID with user ID match
for endpoint in response_decoded['data']['endpoints']:
#This try exists, because fct_users is not available on an endpoint with no last seen user
try:
for fct_user in endpoint['fct_users']:
if fct_user['machine_user_name'] == user_name:
user_id.append(fct_user['fct_user_id'])
except KeyError:
continue
if endpoint['last_seen_fct_user_id'] in user_id:
print(f"{user_name} is a last seen user on endpoint {endpoint['name']}, which has ID {endpoint['device_id']}.")
else:
print(f"{user_name} does not appear in the last seen users of any endpoint.")
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
Deregistering endpoint(s) by name or ID
If you want to deregister an endpoint because this endpoint should no longer use a license, for example, the following script does just that.
You can add known IDs to the deregister_list or match on the name of one or multiple endpoints using the endpoint_names_list
Deregister endpoint(s) by name or ID
'''
ems_deregister_endpoints.py
Deregister named endpoint(s) or IDs using the FortiClient EMS API
'''
import json
import requests
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Disable warnings
requests.urllib3.disable_warnings()
#Set some variables for the API
ems_server = '192.168.1.208'
#Add the names of endpoints to this list
endpoint_names_list = ["WIN11-CLIENT", "FCXLAB"]
#Add known endpoint IDs you want to deregister to this list
deregister_list = []
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
endpoints_url = f'{api_url_prefix}/endpoints/index'
deregister_url = f'{api_url_prefix}/clients/deregister'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
deregister_data = {}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
response = session.get(url=endpoints_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get endpoint ID with name match and add to deregister list, then format data for API call
for endpoint in response_decoded['data']['endpoints']:
if endpoint['name'] in endpoint_names_list:
deregister_list.append(endpoint['device_id'])
deregister_data = {"ids": deregister_list}
#Deregister endpoint(s)
session.post(url=deregister_url, json=deregister_data, headers=change_headers, verify=False, timeout=30)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
If you want to deregister all endpoints where a user name is in the last seen users, use this script.
Deregister endpoint(s) by user name
'''
ems_deregister_endpoint_named_user.py
Deregister endpoint using a user name, which is also the last seen user, on the endpoint using the FortiClient EMS API
'''
import json
import requests
#Disable warnings
requests.urllib3.disable_warnings()
#Set credentials
username = 'apiadmin'
password = 'Start123$'
#Set some variables for the API
ems_server = '192.168.1.208'
user_name = "adkevin"
user_id = [] #This is a list, because a user can be associated with multiple endpoints
deregister_list = []
#Set all used URLs
api_url_prefix = f'https://{ems_server}/api/v1'
login_url = f'{api_url_prefix}/auth/signin'
logout_url = f'{api_url_prefix}/auth/signout'
endpoints_url = f'{api_url_prefix}/endpoints/index'
deregister_url = f'{api_url_prefix}/clients/deregister'
#Variables for data and headers
auth_data = {"name": f"{username}", "password": f"{password}"}
api_headers = {"Content-type": "application/json"}
deregister_data = {}
#Setup session, login to EMS, and set new headers with CSRF token and referer
session = requests.Session()
login_response = session.post(url=login_url, json=auth_data, headers=api_headers, verify=False, timeout=30)
change_headers = {"Content-type": "application/json", "Referer": f"https://{ems_server}", "X-CSRFToken": f"{session.cookies["csrftoken"]}"}
response = session.get(url=endpoints_url, headers=api_headers, verify=False, timeout=30)
response_decoded = json.loads(response.content.decode('utf-8'))
#Get user FortiClient ID with name match, then get endpoint ID with user ID match
for endpoint in response_decoded['data']['endpoints']:
#If there is no recorded user on an endpoint the fct_users key does not exist
try:
for fct_user in endpoint['fct_users']:
if fct_user['machine_user_name'] == user_name:
#All user IDs get added to a list, because the ID can be associated with multiple endpoints
user_id.append(fct_user['fct_user_id'])
#Only if the user ID is the last seen user on the endpoint, the endpoint will be deregistered
if endpoint['last_seen_fct_user_id'] in user_id:
deregister_list.append(endpoint['device_id'])
deregister_data = {"ids": deregister_list}
except KeyError:
continue
#Deregister endpoints that have the user name as the last seen user, which can be multiple
response = session.post(url=deregister_url, json=deregister_data, headers=change_headers, verify=False, timeout=30)
response_decoded = response.content.decode('utf-8')
print(response_decoded)
#Perform a logout
session.post(url=logout_url, headers=change_headers, verify=False, timeout=30)
After all this, one topic that is conspicuous by its absence is the EMS settings, i.e. the listen address, keepalives, license timeouts, etc.
You can actually set these things using the API with the /api/v1/settings/server/set using a PATCH, but it doesn’t do anything, despite the response stating “Settings updated successfully”.
This might be related to the fact that, if you reverse engineer it, you see that this is form-based, like authentication servers.
A list of undocumented API endpoints
This is a non-exhaustive list of API endpoints I noticed are missing from the official documentation while creating all of this. There are more, but I didn’t look very hard, and I didn’t bother configuring some things to check for endpoints.
This list is valid as of 2026-08-22.
/api/v1/on_net_rules/index
/api/v1/on_net_rules/create
/api/v1/on_net_rules/{ID}/rules/get
/api/v1/on_net_rules/{ID}/rules/update
/api/v1/on_net_rules/{ID}/rules/delete
/api/v1/assignable_installers/index
/api/v1/assignable_installers/create
/api/v1/assignable_installers/{ID}/get
/api/v1/assignable_installers/{ID}/update
/api/v1/assignable_installers/{ID}/delete
/api/v1/group_containers/domains/index
/api/v1/idps/index
/api/v1/idps/{GUID}/delete
/api/v1/idps/adfs/test
/api/v1/idps/{GUID}/get
/api/v1/idps/adfs/{GUID}/update
/api/v1/idps/adfs/{GUID}/imported_ous
/api/v1/connectors/index
/api/v1/server_certificates/index
/api/v1/settings/server/get
/api/v1/settings/server/set
/api/v1/system/cloud/repackager/status
/api/v1/settings/server/addresses/get
/api/v1/admins/create
/api/v1/admins/{ID}/get
/api/v1/admins/{ID}/update
/api/v1/admins/{ID}/delete
/api/v1/profiles/{COMPONENT_TYPE}/index
/api/v1/profiles/{COMPONENT_TYPE}/{ID}/get
/api/v1/profiles/{COMPONENT_TYPE}/{ID}/delete
/api/v1/profiles/ztna/saas_applications
/api/v1/roles/index
/api/v1/client_certificates/group_index
/api/v1/client_certificates/index
/api/v1/client_certificates/set
/api/v1/oauth2_fabric_connectors/index
/api/v1/fabric_device_auth/{FGT_SERIAL}/update
/api/v1/client_certificates/delete
/api/v1/ztna_apps/index
/api/v1/troubleshoot/get
/api/v1/idps/{GUID}/live_navigate
/api/v1/system/info
/api/v1/endpoints/connection/donut
/api/v1/endpoints/management/donut
/api/v1/license/get
/api/v1/forti_care/get
/api/v1/logs/index
/api/v1/logs/count
/api/v1/users/local/index
/api/v1/users/local/create
/api/v1/users/local/{ID}/update
/api/v1/users/local/{ID}/delete
/api/v1/users/{ID}/endpoints/count
/api/v1/ztna_certificates/crl/download/base
/api/v1/ztna_certificates/crl/download/delta
/api/v1/ztna_certificates/download
Wrapping up
A lot was covered today, but I still feel it is a light post, since most of the work was scripting and troubleshooting. I know this post comes across as negative, and I don’t feel positive about the API, but it’s not my intention to make anyone feel bad. If the documentation and the API get better because of this, I see that as a great success. If someone feels helped by this post, then I consider that an even greater success.
Dear reader, do you feel helped?
A shoutout to Maximilian Schiffner from Fortinet for this post. He is one of the greatest Fortinet engineers I know, and I don’t say that just because he’s Austrian. I got the idea for this post because of a single sentence in an email he wrote.
Proxmox is gaining a lot of traction in recent years, not just because it’s a great product and there isn’t nearly enough content out there about it. To help the people after me, and because I did this for work anyway, I created a short video on how to install FortiWeb on Proxmox and migrate the configuration from an existing FortiWeb instance running on VMware, so dear reader, watch and, hopefully, learn.
On that restore problem
I legitimately cannot explain why the restore process doesn’t work if you do it with a new file. I have attempted a lot of things, even doing it with a backup from the Proxmox FortiWeb, which also didn’t work, and the only difference I could see on the surface is the file size. That shouldn’t be a problem, but I can’t explain it otherwise. The only way I found to perform the restore is to work with either the original file or a direct copy of the file. Alas, it’s a mystery, but I found a way to do it, and that counted for my work.
Wrapping up
It’s a video post, so there isn’t a lot here, but thank you, dear reader, for your time today.
Do you want redundancy? Don’t answer that question; the answer is “Yes!” This means you want your FortiClient EMS deployment to be redundant, and this gives you the problem of how to handle the FortiClients and FortiGate connection to your HA EMS nodes if you don’t have an external load balancer.
Well, dear reader, I’ve got just the solution for you, so let’s see how it’s done.
The setup
FortiGate 70G on 7.6.7
2x FortiClient EMS on 7.4.7
Windows 11 client on 25H2 with FortiClient 7.4.7
As far as layer 3 is concerned, I have a CLIENT and DMZ VLAN, where, respectively, the client and the EMS instances are located, as well as the UNUSED VLAN, which gets used later.
First things first. There are several HA deployment options, and I specifically cover the one using only VM appliances. As the documentation states, this requires FortiClient EMS 7.4.5, so keep this in mind.
Setting the cluster up is not hard; it took me about 5 minutes, and there are no additional licenses necessary; you just need the resources for the VM.
A few points here:
In order to change the priority of the nodes, you use the ha standby command on the current primary (use ha get nodes to see the cluster state). There are two roles in the cluster: the database and EMS
If you want to demote the current primary for both roles, you use ha standby --type=”db” and ha standby --type=”ems”. The documentation linked above only mentions it but doesn’t give an example of how to do it in the document. You need the CLI reference for this.
In the following example, EMS2 is the primary for both roles at first and gets demoted
I have removed the “Preferred DCs” column so it fits nicely in here, and because it’s empty anyway
ems@EMS2 $> ha get nodes
EMS Node(s):
Name | Role | Status | Last Seen
--------------------------------------------------+---------+---------+----------------------------
EMS2 (*) | primary | online | 2026-06-18 18:32:57.841091
EMS1 | standby | online | 2026-06-18 18:33:00.042757
DB Node(s):
Host | Port | Role | Status | Latency (ms)
--------------------------------------------------+---------+---------+------------
192.168.1.177 | 5432 | standby | online | 28
127.0.0.1 | 5432 | primary | online | 27
ems@EMS2 $> ha standby --type="db"
Node demoted successfully!
ems@EMS2 $> ha standby --type="ems"
Node demoted successfully!
ems@EMS2 $> ha get nodes
EMS Node(s):
Name | Role | Status | Last Seen
--------------------------------------------------+---------+---------+----------------------------
EMS1 | primary | online | 2026-06-18 18:35:10.04539
EMS2 (*) | standby | online | 2026-06-18 18:35:01.322546
DB Node(s):
Host | Port | Role | Status | Latency (ms)
--------------------------------------------------+---------+---------+------------
192.168.1.177 | 5432 | primary | online | 27
127.0.0.1 | 5432 | standby | online | 26
The telemetry service, TCP/8013, is only active on the current primary, and the HTTPS service is active on both nodes
This is an important point for later
EMS does not have its own virtual IP or load balancer service, like haproxy, so any outside connections to it have to get load balanced via something. If you have an external load balancer, this is simple, but that’s a luxury not everyone has
The failover time is calculated using the following formula: High Availability Keep Alive Interval * 2 + 60
The High Availability Keep Alive Interval is configured in the EMS Settings menu
With default settings, this means it takes 80 seconds for a failover to occur
With the basics covered, let’s connect things to EMS.
The FortiClient to EMS connection
This one is easy. You can use the Load Balance feature on the FortiGate to accomplish this (it has to be enabled in Feature Visibility first), and then you can configure everything in the Virtual Servers menu under Policy & Objects.
For FortiClients, we need, at the minimum, TCP/8013 for the telemetry, so create your virtual server using that as the port and also the health check (either in-line or using the Health Check menu). If you need the installer port, default TCP/11443, you can do that too.
config firewall vip
edit "EMS-HA-FORTICLIENT"
set type server-load-balance
set server-type tcp
set extip 192.168.1.190
set extintf "CLIENTS"
set monitor "tcp8013"
set extport 8013
config realservers
edit 1
set ip 192.168.1.177
set port 8013
next
edit 2
set ip 192.168.1.178
set port 8013
next
end
next
end
With the virtual server created, you can create your policy accordingly. Note that if you use the type TCP in your virtual server, the policy can be created using the flow-based inspection mode, which means hardware offloading. You only need proxy-based for non-TCP/UDP/IP types. Attach security profiles to the policy as necessary.
That’s everything you need for FortiClient to connect to EMS. Use the FQDN or an invitation code in the FortiClient GUI and hit Connect. Simple, right?
The FortiGate to EMS connection
This is the one that required some time to get working because the FortiGate needs to load-balance its own traffic, which I didn’t think was actually possible, but apparently it is.
We, again, start with the virtual server, and we need TCP/443 here, but crucially, the health check must not be TCP/443. As mentioned above, the HTTPS service is active on both nodes, so this would create immediate issues depending on your load-balancing method and, in general, create issues.
What service is only active on the primary node? The telemetry service, so we reuse the health check for TCP/8013, we already used for the FortiClient connection. The real servers still use TCP/443, however.
I am binding the virtual server to the UNUSED VLAN because I only need to put the virtual server in a policy to activate it. This is done more so to show that this is possible. In reality, you would probably bind it to a managementinterface, so if you go to the virtual server IP, you always land on the active EMS node’s GUI.
config firewall vip
edit "EMS-HA-FORTIGATE"
set type server-load-balance
set server-type tcp
set extip 192.168.1.190
set extintf "LOOPBACK"
set monitor "tcp8013"
set extport 443
config realservers
edit 1
set ip 192.168.1.177
set port 443
next
edit 2
set ip 192.168.1.178
set port 443
next
end
next
end
With this in mind, the policy we need is nothing special. The virtual server just needs to be active, and again, we can use a flow-based policy. Attach security profiles to the policy as necessary.
In the Fabric Connector for EMS, we use the FQDN, which resolves to the virtual server IP, and at least in my configuration, nothing happens because the traffic gets sourced incorrectly.
To solve this, you go to the CLI and set a source IP for this connection. I am using the IP that the DMZ interface has, so that will be the one I see on EMS.
Since we’re already in the CLI, we can use the command execute fctems verify <ID> (insert your ID) to start the verification, which should give us a certificate to accept.
FortiGate EMS fabric CLI configuration
70G-EMSHA # config endpoint-control fctems
70G-EMSHA (fctems) # edit 1
70G-EMSHA (1) # set source-ip 192.168.1.202
70G-EMSHA (1) # end
The configuration will not be effective unless server certificate is verified.
You can get and verify server certificate by the following command:
"execute fctems verify 1" (ems table id)
70G-EMSHA # execute fctems verify 1
Subject: CN = ems-ha.ad.labdomain.com
Issuer: DC = com, DC = labdomain, DC = ad, CN = WIN-CA
Valid from: 2026-06-18 08:10:35 GMT
Valid to: 2028-06-17 08:10:35 GMT
Fingerprint: 6D:6B:80:BC:10:9B:31:F7:15:7A:CC:03:71:01:98:9C:97:A0:4D:C5:21:12:C8:10:15:31:F3:43:E8:9B:2E:A7
Root CA: No
Version: 3
Serial Num:
32:00:00:00:44:88:6a:ed:ca:e6:cb:5b:39:00:02:00:00:00:44
Extensions:
Name: X509v3 Subject Key Identifier
Critical: no
Content:
BC:33:E8:D9:39:2F:29:36:21:E2:B7:88:9A:70:8D:79:63:F7:C6:55
Name: X509v3 Key Usage
Critical: yes
Content:
Digital Signature, Key Encipherment
Name: X509v3 Subject Alternative Name
Critical: no
Content:
DNS:ems-ha.ad.labdomain.com, DNS:ems1.ad.labdomain.com, DNS:ems2.ad.labdomain.com
Name: X509v3 Authority Key Identifier
Critical: no
Content:
F3:CD:D6:6C:D4:C1:35:68:D7:EE:AA:07:7A:A8:5A:73:B8:48:6C:D5
Name: X509v3 CRL Distribution Points
Critical: no
Content:
Full Name:
URI:ldap:///CN=WIN-CA(2),CN=WIN-AD,CN=CDP,CN=Public%20Key%20Services,CN=Services,CN=Configuration,DC=ad,DC=labdomain,DC=com?certificateRevocationList?base?objectClass=cRLDistributionPoint
URI:http://WIN-AD.ad.labdomain.com/CertEnroll/WIN-CA(2).crl
Name: Authority Information Access
Critical: no
Content:
CA Issuers - URI:ldap:///CN=WIN-CA,CN=AIA,CN=Public%20Key%20Services,CN=Services,CN=Configuration,DC=ad,DC=labdomain,DC=com?cACertificate?base?objectClass=certificationAuthority
CA Issuers - URI:http://WIN-AD.ad.labdomain.com/CertEnroll/WIN-AD.ad.labdomain.com_WIN-CA(2).crt
Name: Microsoft certificate template
Critical: no
Content:
0-.%+.....7.....n...z...%...w........G..f..d...
Name: X509v3 Extended Key Usage
Critical: no
Content:
TLS Web Server Authentication
Name: Microsoft Application Policies Extension
Critical: no
Content:
0.0
..+.......
EMS configuration needs user to confirm server certificate.
Do you wish to add the above certificate to trusted remote certificates? (y/n)y
Certificate successfully configured and verified.
Once we have entered the good old y, we can go to EMS and authorize the FortiGate that appeared.
Wrapping up
And that’s that. You can do your failover tests (mine worked), and hopefully, dear reader, you can now connect your FortiClients and your FortiGate to your EMS HA deployment. Additional FortiGates should be much easier to handle; it’s just the first one that requires a bit of special attention.
Certificates are ubiquitous in our daily life, whether we realize it or not, and most people fall into one of two camps when it comes to them:
They love them because they make authentication and trust easy
They hate them because they can seem like black magic to some and create unnecessary work due to having to provision and replace them.
Both sides have valid points, but we can all agree that we need them, be it for securing web traffic, enabling strong authentication or to get rid of those pesky certificate warnings on our GUIs.
FortiClient EMS has a few options to interact with certificates, so, dear reader, let’s have a look to see how EMS can help us with managing certificates.
FortiClient EMS does two things out of the box when it comes to certificates:
It acts as a certificate authority (CA), using a self-signed ZTNA CA to…
Provision managed FortiClients with a user certificate for ZTNA purposes
These two things open up quite a few possibilities because the user certificates aren’t anything special. They are regular certificates for client authentication, and since EMS is already distributing the certificates, we might as well put them to good use.
We could download the default ZTNA public CA certificate and use that in our infrastructure to authenticate users, but this has some caveats, like:
It’s a self-signed CA that has no connection to anything, which can be seen as a problem by some people
We cannot issue certificates for other services, like webservers, using this CA, because the private key lies with EMS, and EMS only gives certificates to managed FortiClients
Corollary, this cannot scale because I only have this one CA, and I can’t create sub/intermediate CAs
To solve this, I can create a real public key infrastructure (PKI) with a root CA, several sub CAs, including an EMS CA, hand out certificates for all purposes, and I got a nice chain of trust, and that is what I want to show in this post.
Note: None of the use cases I cover in this post requires running your own PKI. You can do everything in some form using the default certificates and CAs you get with FortiClient EMS and a FortiGate, and without a root CA. I am doing this more to show that you can take it further.
With the preamble out of the way, let’s begin with the foundation.
The certificate structure
To get started, I will be using FortiAuthenticator as a root CA to create two sub CAs:
The EMS_SUBCA, which replaces the default EMS ZTNA CA and signs the certificates for managed FortiClients
The FGT_SUBCA, which will be used by the FortiGate for SSL/TLS inspection
Very important: I will use EMS_SUBCA in this post to refer to the sub CA that EMS uses, but the CN for this CA must be the serial number of your EMS server. This is a requirement for everything to function correctly.
Additionally, I create one certificate with the server authentication extended keyusage (EKU), so the FortiGate can later authenticate VPN users.
Note: I want to mention that the PKI topology I use for this post is not best practice. In a production environment, you should always start with a root CA that gets taken offline after issuing a sub CA, and that sub CA then issues all other certificates. To make it a bit easier on myself, I am only using an online root CA.
How do we get to our certificates?
FortiAuthenticator certificate management
With FortiAuthenticator, we can do basically everything, so if we just head to Certificate Management -> Certificate Authorities -> Local CAs, we can create the root CA using the provided Root CA type, and after providing an ID and a CN, we got the first part done. You can, of course, change the settings for the CA as you wish.
With the root CA in hand, we can create the EMS and FortiGate sub CAs from the same menu using the Intermediate CA type. Again, it’s very important that the Common Name is the serial number of your EMS server. I only show the EMS sub CA here.
After creating, make sure to export the key and cert for the two CAs and keep them safe for now. Also, download the public keys using the Export Certificate button from all three CAs.
We need a certificate for the FortiGate later, and I will do that using the Simple Certificate Enrollment Protocol (SCEP) on FortiAuthenticator. Don’t forget to enable this service on the interface on which the FortiGate contacts FortiAuthenticator. If you use HTTPS, make sure that FortiAuthenticator’s certificate is trusted by the FortiGate.
Then head over to the SCEP Enrollment Requests menu and create a regular request for the FortiGate, signed by the root CA, with a fitting CN, and make sure that the Extended Key Usage of Server Authentication is present. All settings can, of course, be changed as you wish; I am just presenting a basic configuration.
Note the password after creating the request, because we need it right now, because we get to…
FortiGate certificate management
Be sure to enable Certificates in Feature Visibility first.
On the FortiGate, first import the FortiAuthenticator root CA and EMS sub CA certificates. You can do this either using SCEP or via a file upload.
Then create a certificate signing request (CSR), making sure that the name and domain name match the CN you presented in the SCEP enrollment request (fgt-vpncert for me) and that Online SCEP is used as the Enrollment Method. The CA Server URL is in the format of http://FORTIAUTHENTICATOR-IP-FQDN/app/cert/scep, with HTTPS if you want, and your own IP or FQDN, of course.
The Challenge Password is the one you got earlier.
After a few seconds, the status of the CSR should get signed by FortiAuthenticator and return a Valid status.
While you’re here, also import the sub CA certificate for the FortiGate. Remember that this is considered a Certificate, not a CA certificate, when you click on the Create/Import button.
That’s it for now, so let’s turn to EMS.
EMS certificate management
On EMS, we have to do two things:
Change the ZTNA CA certificate to our created one
Make sure clients get the FortiGate’s sub CA certificate
Point one is done by going to System Settings -> EMS Settings and clicking on the cog icon next to EMS CA Certificate (ZTNA) and uploading the new CA certificate. EMS will automatically hand out certificates to managed FortiClients using this certificate.
For point two, we first need to upload the CA certificate under Endpoint Policy & Components -> CA Certificates.
With the uploaded certificate, go to your System Settings profile of choice, make sure that under Other, the Install CA Certificate on Client setting is enabled and pick the CA certificates you want to push to managed FortiClients. Note that the certificate used as the EMS ZTNA CA gets installed without having to do anything.
After these two steps and waiting for a sync, we can see that a connected client now has a user certificate from the new EMS sub CA and trusts the CA certificates that are in the System Settings profile.
Note: If you have an on-net endpoint, make sure that whatever you configure in the ZTNA profile does not interfere with normal operations. Imagine that you have a ZTNA destination that uses the FQDN of an internal server. Due to the way ZTNA works, FortiClient will intercept the DNS request, return a bogus IP, and try to ZTNA this connection, which probably means the connection will fail. You probably want to have a disabled ZTNA profile attached to the endpoint’s policy when it is on-net.
With all our certificates in order, we can actually use them, and why not begin with VPN?
For VPN
Note: Since EMS is provisioning user certificates, the certificate can only be used for regular VPN authentication with a logged-in user and pre-logon/start before logon (SBL) is not possible, since only machine certificates can be used in a pre-logon state.
Starting with the FortiGate, we first need a PKI user that represents the EMS sub CA, so create this PKI user.
config user peer
edit "EMS-SUBCA"
set ca "EMS_SUBCA"
next
end
The VPN configuration is relatively simple; just make sure that the previously created server authentication certificate is used and only accept client certificates from the EMS sub CA. All other settings, like IP assignment, encryption, DH, etc., are up to you.
Don’t forget to create a policy using the VPN interface; otherwise, clients can’t connect.
FortiGate certificate authentication VPN
config vpn ipsec phase1-interface
edit "CERT-VPN"
set type dynamic
set interface "port1"
set ike-version 2
set keylife 28800
set authmethod signature
set net-device disable
set mode-cfg enable
set ipv4-dns-server1 192.168.1.240
set proposal aes256gcm-prfsha512
set dhgrp 21
set client-resume enable
set client-resume-interval 300
set transport auto
set certificate "fgt-vpncert"
set peer "EMS-SUBCA"
set ipv4-start-ip 172.16.100.1
set ipv4-end-ip 172.16.100.20
set ipv4-split-include "BASE-RFC1918-192"
next
end
config vpn ipsec phase2-interface
edit "CERT-VPN"
set phase1name "CERT-VPN"
set proposal aes256-sha256
set dhgrp 21
set keepalive enable
set keylifeseconds 3600
next
end
config firewall policy
edit 0
set name "CERT-VPN"
set srcintf "CERT-VPN"
set dstintf "LAN"
set action accept
set srcaddr "all"
set dstaddr "all"
set schedule "always"
set service "ALL"
set logtraffic all
set nat enable
next
end
On EMS, we need a matching VPN configuration in the Remote Access profile. There are a few things to note:
The basic idea is that the certificate for the VPN connection is filtered, so only a specific one appears, which makes the user experience better because you don’t have to worry about which certificate gets used by a user. In my configuration, I am doing a regex match on the common name and a simple match on the issuer, which is enough to get what I need.
The FortiClient XML reference has some more information on how to match and what options are available.
I am configuring the session resumption feature for this tunnel, by the way.
Both the FortiGate VPN configuration and the Remote Access XML profile EMS are in the GitHub repository linked above.
A bit of advice when you first set this up: Create a personal VPN on a FortiClient, recreate the necessary VPN settings, and make sure that works first, just so you know that certificate authentication works. Once that is done, test the VPN configuration you get from EMS. Especially with the certificate filtering, it’s easier to do this bit by bit and not start with the EMS-provided VPN tunnel.
With the FortiGate and EMS having the correct VPN configuration, we can test the connection, and it should work. We can see that the CN matches the FortiClient ID we see on EMS.
If we look at the debugs of ike and fnbamd, we can see the entire process (some parts of the debugs are removed):
FortiGate VPN debugs
diagnose debug application ike -1
diagnose debug application fnbamd -1
diagnose debug enable
ike V=root:0: comes 192.168.100.2:51056->192.168.100.1:4500,ifindex=5,vrf=0,len=486....
ike V=root:0: IKEv2 exchange=SA_INIT id=5a88db4c560f15da/0000000000000000 len=482
ike 0: in 5A88DB4C560F15DA00000000000000002120220800000000000001E22200006C02000024010100030300000C01000014800E0100030000080400001500000008020000070000004402010007030000080300000C0300000C0100000C800E0100030000080400001503000008020000050300000802000006030000080200000700000008020000022800008C0015000000C99A8B88C2DDEE9963B6D3B52C40B57927668DC1C786D1981C35B1AD5638F56CC380D5280C6905FCF86C1FCBA01C8677249BA6C19290C86DAD24694C3B63F61D750102AB27393AB8D9D29E48D099E36A6BEF6BB06895B64DA96A2E67AF9D8481DCED49F36FA2C2B1FA09C117751D029699749DD359090740383B7EFFE6BCCFA47BE19A2B00002441A822057CFB67B96D2DBA82EE1C490EEA106F34B30490242EE4229FBABB92122B00001C4C53427B6D465D1B337BB755A37A7FEF706B9EBAF67F00002B00001CB4F01CA951E9DA8D0BAFBBD34AD3044E906B9EBAF67F00002900001CC1DC4350476B98A429B91781914CA43E202A8801FD010000290000080000F118290000080000402E2900001C0000400468F9B3E80FB82683054FF61FC4BFF1639CBAD7672900001C0000400540DAB0491BAC2CEB4C65D6506300387954912FAE0000000E0000402F000200030004
ike V=root:0:5a88db4c560f15da/0000000000000000:23: responder received SA_INIT msg
ike V=root:0:5a88db4c560f15da/0000000000000000:23: VID forticlient connect license 4C53427B6D465D1B337BB755A37A7FEF706B9EBAF67F0000
ike V=root:0:5a88db4c560f15da/0000000000000000:23: VID Fortinet Endpoint Control B4F01CA951E9DA8D0BAFBBD34AD3044E906B9EBAF67F0000
ike V=root:0:5a88db4c560f15da/0000000000000000:23: VID Forticlient EAP Extension C1DC4350476B98A429B91781914CA43E202A8801FD010000
ike V=root:0:5a88db4c560f15da/0000000000000000:23: received notify type CLIENT_RESUME
ike V=root:0:5a88db4c560f15da/0000000000000000:23: received notify type FRAGMENTATION_SUPPORTED
ike V=root:0:5a88db4c560f15da/0000000000000000:23: received notify type NAT_DETECTION_SOURCE_IP
ike V=root:0:5a88db4c560f15da/0000000000000000:23: received notify type NAT_DETECTION_DESTINATION_IP
ike V=root:0:5a88db4c560f15da/0000000000000000:23: received notify type SIGNATURE_HASH_ALGORITHMS
ike V=root:0:5a88db4c560f15da/0000000000000000:23: incoming proposal:
ike V=root:0:5a88db4c560f15da/0000000000000000:23: proposal id = 1:
ike V=root:0:5a88db4c560f15da/0000000000000000:23: protocol = IKEv2:
ike V=root:0:5a88db4c560f15da/0000000000000000:23: encapsulation = IKEv2/none
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=ENCR, val=AES_GCM_16 (key_len = 256)
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=PRF, val=PRF_HMAC_SHA2_512
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=DH_GROUP, val=ECP521.
ike V=root:0:5a88db4c560f15da/0000000000000000:23: proposal id = 2:
ike V=root:0:5a88db4c560f15da/0000000000000000:23: protocol = IKEv2:
ike V=root:0:5a88db4c560f15da/0000000000000000:23: encapsulation = IKEv2/none
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=ENCR, val=AES_CBC (key_len = 256)
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=INTEGR, val=AUTH_HMAC_SHA2_256_128
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=PRF, val=PRF_HMAC_SHA
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=PRF, val=PRF_HMAC_SHA2_512
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=PRF, val=PRF_HMAC_SHA2_384
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=PRF, val=PRF_HMAC_SHA2_256
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=DH_GROUP, val=ECP521.ike V=root:0: cache rebuild start
ike V=root:0:CERT-VPN: cached as wildcard, user peer 'EMS-SUBCA'
ike V=root:0:CERT-VPN: cached as dynamic, user peer 'EMS-SUBCA' subj='' cn=''
ike V=root:0: cache rebuild done
ike V=root:0:5a88db4c560f15da/0000000000000000:23: matched proposal id 1
ike V=root:0:5a88db4c560f15da/0000000000000000:23: proposal id = 1:
ike V=root:0:5a88db4c560f15da/0000000000000000:23: protocol = IKEv2:
ike V=root:0:5a88db4c560f15da/0000000000000000:23: encapsulation = IKEv2/none
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=ENCR, val=AES_GCM_16 (key_len = 256)
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=INTEGR, val=NONE
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=PRF, val=PRF_HMAC_SHA2_512
ike V=root:0:5a88db4c560f15da/0000000000000000:23: type=DH_GROUP, val=ECP521.
ike V=root:0:5a88db4c560f15da/0000000000000000:23: lifetime=28800
ike V=root:0:5a88db4c560f15da/0000000000000000:23: SA proposal chosen, matched gateway CERT-VPN
ike V=root:0:CERT-VPN:23: sending CERTREQ payload (len=21)
ike V=root:0:CERT-VPN:23: certreq[0]: '85936D8D9D330144B1FBE59B30BE1EF36474F48F'
ike V=root:0:CERT-VPN:23: received peer identifier DER_ASN1_DN 'C = CA, ST = BC, L = Burnaby, O = Fortinet, CN = F3AB0220E981419CAA9A13CE4811E3F9'ike V=root:0:CERT-VPN:23: match gw peer ID by FNBAMike V=root:0:CERT-VPN:23: Validating X.509 certificate
ike V=root:0:CERT-VPN:23: peer cert, subject='F3AB0220E981419CAA9A13CE4811E3F9', issuer='EMS_SUBCA'
ike V=root:0:CERT-VPN:23: peer ID verified
ike V=root:0:CERT-VPN:23: building fnbam peer candidate list
ike V=root:0:CERT-VPN:23: FNBAM_GROUP_NAME candidate 'EMS-SUBCA'
ike V=root:0:CERT-VPN:23: certificate validation pending
[323] fnbamd_chain_build-Chain discovery, opt 0x13, cur total 1
[341] fnbamd_chain_build-Following depth 0
[376] fnbamd_chain_build-Extend chain by system trust store. (good: 'EMS-SUBCA')
[341] fnbamd_chain_build-Following depth 1
[376] fnbamd_chain_build-Extend chain by system trust store. (good: 'FAC_ROOT')
[341] fnbamd_chain_build-Following depth 2
[355] fnbamd_chain_build-Self-sign detected.
[109] __cert_chg_st- 'Init' -> 'Validation'
[1025] __cert_verify-req_id=40737833275404
[1026] __cert_verify-Chain is complete.
[540] fnbamd_cert_verify-Chain number:3
[554] fnbamd_cert_verify-Following cert chain depth 0
[627] fnbamd_cert_verify-Issuer found: EMS-SUBCA (SSL_DPI opt 1)
[554] fnbamd_cert_verify-Following cert chain depth 1
[627] fnbamd_cert_verify-Issuer found: FAC_ROOT (SSL_DPI opt 1)
[554] fnbamd_cert_verify-Following cert chain depth 2
[1057] __cert_verify-peer_info.no_ocsp_query:0 cert->status:640.
[733] fnbamd_cert_check_group_list-checking group with name 'EMS-SUBCA'
[546] __check_add_peer-check 'EMS-SUBCA'
[422] peer_subject_cn_check-Cert subject 'C = CA, ST = BC, L = Burnaby, O = Fortinet, CN = F3AB0220E981419CAA9A13CE4811E3F9'
[553] __check_add_peer-'EMS-SUBCA' check ret:good
[668] __peer_user_clear_unmatched-Clear all user(s) other than 'EMS-SUBCA'
[689] __peer_user_clear_unmatched-
[202] __get_default_ocsp_ctx-def_ocsp_ctx=(nil), no_ocsp_query=0, ocsp_enabled=0
[806] fnbamd_cert_check_group_list-Peer users
[809] fnbamd_cert_check_group_list- 'EMS-SUBCA' ('N/A','N/A','N/A')
[1069] __cert_verify_do_next-req_id=40737833275404
[109] __cert_chg_st- 'Validation' -> 'Done'
[1163] __cert_done-req_id=40737833275404
[1567] fnbamd_auth_session_done-Session done, id=40737833275404
[1209] __fnbamd_cert_auth_run-Exit, req_id=40737833275404
[1610] create_auth_cert_session-fnbamd_cert_auth_init returns 0, id=40737833275404
[1523] auth_cert_success-id=40737833275404
[1321] fnbamd_cert_auth_copy_cert_status-req_id=40737833275404
[1329] fnbamd_cert_auth_copy_cert_status-Matched peer user 'EMS-SUBCA'
[914] fnbamd_cert_check_matched_groups-checking group with name 'EMS-SUBCA', peer_ctx->peer_user->setting.name:EMS-SUBCA
[975] fnbamd_cert_check_matched_groups-matched
[1361] fnbamd_cert_auth_copy_cert_status-Leaf cert status is unchecked.
[1452] fnbamd_cert_auth_copy_cert_status-Cert st 2c0, req_id=40737833275404
[279] fnbamd_comm_send_result-Sending result 0 (nid 672) for req 40737833275404, len=2611
[1398] destroy_auth_cert_session-id=40737833275404
[1293] fnbamd_cert_auth_uninit-req_id=40737833275404
ike V=root:0:CERT-VPN:23: fnbam reply 'EMS-SUBCA'
ike V=root:0:CERT-VPN:23: fnbam matched peer 'EMS-SUBCA'
[1985] fnbamd_ldaps_destroy-
ike V=root:0:CERT-VPN:23: certificate validation succeeded
[1667] fnbamd_rads_destroy-
[140] fnbamd_peer_ctx_free-Freeing peer ctx 'EMS-SUBCA'
ike V=root:0:CERT-VPN:23: signature verification succeeded
ike V=root:0:CERT-VPN:23: auth verify done
ike V=root:0:CERT-VPN:23: responder AUTH continuation
ike V=root:0:CERT-VPN:23: authentication succeeded
ike V=root:0:CERT-VPN:23: processing notify type FORTICLIENT_CONNECT
ike V=root:0:CERT-VPN:23: received FCT data len = 326, data = 'VER=1
FCTVER=7.4.6.2001
UID=F3AB0220E981419CAA9A13CE4811E3F9
IP=192.168.100.2
MAC=a4-bb-6d-13-07-2b;24-41-8c-fb-b1-b6;24-41-8c-fb-b1-b7;26-41-8c-fb-b1-b6;
HOST=FCXLAB
USER=labuser
OSVER=Microsoft Windows 8.0 Professional Edition, 64-bit (build 9200)
REG_STATUS=0
EMSSN=FCTEMSSERIAL
EMSID=00000000000000000000000000000000
'
ike V=root:0:CERT-VPN:23: received FCT-UID : F3AB0220E981419CAA9A13CE4811E3F9
ike V=root:0:CERT-VPN:23: received EMS SN : FCTEMSSERIAL
ike V=root:0:CERT-VPN:23: received EMS tenant ID : 00000000000000000000000000000000
ike V=root:0:CERT-VPN:23: received FCT-HOST : FCXLAB
ike V=root:0:CERT-VPN:23: responder creating new child
ike V=root:0:CERT-VPN:23: mode-cfg type 1 request 0:''
ike V=root:0:CERT-VPN: mode-cfg allocate 172.16.100.1/0.0.0.0
ike V=root:0:CERT-VPN:23: mode-cfg using allocated IPv4 172.16.100.1
ike V=root:0:CERT-VPN:23:8: peer proposal:
ike V=root:0:CERT-VPN:23:8: TSi_0 0:0.0.0.0-255.255.255.255:0
ike V=root:0:CERT-VPN:23:8: TSr_0 0:0.0.0.0-255.255.255.255:0
ike V=root:0:CERT-VPN:23:CERT-VPN:8: comparing selectors
ike V=root:0:CERT-VPN:23:CERT-VPN:8: matched by rfc-rule-2
ike V=root:0:CERT-VPN:23:CERT-VPN:8: phase2 matched by subset
ike V=root:0:CERT-VPN:23:CERT-VPN:8: using mode-cfg override 0:172.16.100.1-172.16.100.1:0
ike V=root:0:CERT-VPN:23:CERT-VPN:8: accepted proposal:
ike V=root:0:CERT-VPN:23:CERT-VPN:8: TSi_0 0:172.16.100.1-172.16.100.1:0
ike V=root:0:CERT-VPN:23:CERT-VPN:8: TSr_0 0:0.0.0.0-255.255.255.255:0
ike V=root:0:CERT-VPN:23:CERT-VPN:8: dialup
ike V=root:0:CERT-VPN:23:CERT-VPN:8: incoming child SA proposal:
ike V=root:0:CERT-VPN:23:CERT-VPN:8: proposal id = 1:
ike V=root:0:CERT-VPN:23:CERT-VPN:8: protocol = ESP:
ike V=root:0:CERT-VPN:23:CERT-VPN:8: encapsulation = TUNNEL
ike V=root:0:CERT-VPN:23:CERT-VPN:8: type=ENCR, val=AES_GCM_16 (key_len = 128)
ike V=root:0:CERT-VPN:23:CERT-VPN:8: type=ESN, val=NO
ike V=root:0:CERT-VPN:23:CERT-VPN:8: PFS is disabled
ike V=root:0:CERT-VPN:23:CERT-VPN:8: proposal id = 2:
ike V=root:0:CERT-VPN:23:CERT-VPN:8: protocol = ESP:
ike V=root:0:CERT-VPN:23:CERT-VPN:8: encapsulation = TUNNEL
ike V=root:0:CERT-VPN:23:CERT-VPN:8: type=ENCR, val=AES_CBC (key_len = 256)
ike V=root:0:CERT-VPN:23:CERT-VPN:8: type=INTEGR, val=SHA256
ike V=root:0:CERT-VPN:23:CERT-VPN:8: type=ESN, val=NO
ike V=root:0:CERT-VPN:23:CERT-VPN:8: PFS is disabled
ike V=root:0:CERT-VPN:23:CERT-VPN:8: matched proposal id 2
ike V=root:0:CERT-VPN:23:CERT-VPN:8: proposal id = 2:
ike V=root:0:CERT-VPN:23:CERT-VPN:8: protocol = ESP:
ike V=root:0:CERT-VPN:23:CERT-VPN:8: encapsulation = TUNNEL
ike V=root:0:CERT-VPN:23:CERT-VPN:8: type=ENCR, val=AES_CBC (key_len = 256)
ike V=root:0:CERT-VPN:23:CERT-VPN:8: type=INTEGR, val=SHA256
ike V=root:0:CERT-VPN:23:CERT-VPN:8: type=ESN, val=NO
ike V=root:0:CERT-VPN:23:CERT-VPN:8: PFS is disabled
ike V=root:0:CERT-VPN:23:CERT-VPN:8: lifetime=3600
ike V=root:0:CERT-VPN:23: responder preparing AUTH msg
ike V=root:0:CERT-VPN: adding new dynamic tunnel for 192.168.100.2:51056
ike V=root:0:CERT-VPN_0: tunnel created tun_id 172.16.100.1/::10.0.0.10 remote_location 0.0.0.0
ike V=root:0:CERT-VPN_0: added new dynamic tunnel for 192.168.100.2:51056
ike V=root:0:CERT-VPN_0:23: local cert, subject='fgt-vpncert', issuer='FAC-ROOT'
ike V=root:0:CERT-VPN_0:23: mode-cfg assigned (1) IPv4 address 172.16.100.1
ike V=root:0:CERT-VPN_0:23: mode-cfg assigned (2) IPv4 netmask 255.255.255.255
ike V=root:0:CERT-VPN_0:23: mode-cfg send (13) 0:192.168.0.0/255.255.0.0:0
ike V=root:0:CERT-VPN_0:23: mode-cfg send (3) IPv4 DNS(1) 192.168.1.240
ike V=root:0:CERT-VPN_0:23:CERT-VPN:8: IPsec SA selectors #src=1 #dst=1
ike V=root:0:CERT-VPN_0:23:CERT-VPN:8: src 0 7 0:0.0.0.0-255.255.255.255:0
ike V=root:0:CERT-VPN_0:23:CERT-VPN:8: dst 0 7 0:172.16.100.1-172.16.100.1:0
ike V=root:0:CERT-VPN_0:23:CERT-VPN:8: add dynamic IPsec SA selectors 683
ike V=root:0:CERT-VPN_0:23:CERT-VPN:8: added dynamic IPsec SA proxyids new 1 683
ike V=root:0:CERT-VPN:8: add route 172.16.100.1/255.255.255.255 gw 172.16.100.1 oif CERT-VPN(36) metric 15 priority 1
ike V=root:0:CERT-VPN_0:23:CERT-VPN:8: tunnel 1 of VDOM limit 0/0
ike V=root:0:CERT-VPN_0:23:CERT-VPN:8: add IPsec SA: SPIs=78e2bc59/8dc65e44
ike V=root:0:CERT-VPN_0:23:CERT-VPN:8: added IPsec SA: SPIs=78e2bc59/8dc65e44
ike V=root:0:CERT-VPN_0:23:CERT-VPN:8: sending SNMP tunnel UP trap
ike V=root:0:CERT-VPN_0: tunnel up event assigned address 172.16.100.1
ike V=root:0:CERT-VPN_0: sent tunnel-up message to EMS: (fct-uid=F3AB0220E981419CAA9A13CE4811E3F9, intf=CERT-VPN_0, addr=172.16.100.1, vdom=root)
We get the incoming connection with some FortiClient information as well as the IKE phase 1 proposal, so a gateway can be matched
FortiClient sends the user certificate during the connection attempt (received peer identifier) and the peer cert, the EMS sub CA, gets taken from the configuration and passed to the fnbamd process for certificate validation.
The certificate chain gets walked to find the possible issuers (Following cert chain)
The EMS sub CA certificate provides a match (__check_add_peer-'EMS-SUBCA' check ret:good)
The resulting CA gets returned to the ike process (fnbam reply 'EMS-SUBCA) and certificate validation is successful (certificate validation succeeded)
Regular IPsec phase 2 processes are done and we get a sending SNMP tunnel UP trap message, which we love to see
VPN is looking good, so next up is SSL/TLS inspection.
For SSL/TLS inspection
I will be referring to SSL/TLS inspection as Deep Packet Inspection (DPI) going forward.
When doing DPI, the client needs to trust the CA that is issuing the replaced certificate, and since we already did that in the EMS certificate management section, we only need to create an SSL/SSH inspection profile and configure firewall policies to use it.
Remember that you need at least one other security profile in a policy for the SSL/SSH inspection profile to do anything, which is why I am using an application control and IPS profile. Without an additional security profile, it’s like no security profile is being used.
If a client then, for example, browses to a website secured by HTTPS, we see that DPI does its thing, meaning the FortiGate replaces the certificate, and the connection is also trusted.
DPI done, so on to 802.1X.
A detour to configure FortiAuthenticator for EAP-TLS authentication with a FortiGate and FortiSwitch
Initially, I didn’t want to explain how to configure FortiAuthenticator as a RADIUS server for EAP-TLS authentication and point to the documentation, but I couldn’t find an up-to-date example of it, so let’s quickly go through it.
Make sure that at least RADIUS Auth is enabled on the FortiAuthenticator’s interface
If it doesn’t already exist, create a Local Services certificate for EAP authentication in Certificate Management -> End Entities -> Local Services
This certificate may not have multiple SANs, but one is fine
Assign the certificate as the EAP Server Certificate under Authentication -> RADIUS Service -> General
Create a RADIUS client for the device that will send RADIUS requests
For this post, it is only the FortiGate’s IP since I NAT the FortiSwitch communication towards FortiAuthenticator
You don’t need it for 802.1X authentication, but you might as well enable the requirement for the Message-Authenticator attribute and also do it on the Network Access Server (NAS). For MAC Authentication Bypass (MAB), you need it anyway, and the attribute doesn’t hurt
Create an Auth Profile with the Authentication type of Certificates and the EMS_SUBCA as a Trusted CA
Create a RADIUS policy with your RADIUS client, the Client Credentials as EAP-TLS, and pick your Authentication Profile
FortiAuthenticator RADIUS interface
FortiAuthenticator Local Services certificate
FortiAuthenticator EAP Server Certificate
FortiAuthenticator RADIUS client
FortiAuthenticator Authentication profile first step
FortiAuthenticator Authentication profile second step
FortiAuthenticator RADIUS policy first step
FortiAuthenticator RADIUS policy second step
The configuration on the FortiGate is the same as always:
Create a RADIUS server
Create a group with that RADIUS server as a remote member
Create a FortiSwitch Security Policy for 802.1X
You probably want MAC-based authentication so each client gets authenticated, not just the first one, like with port-based authentication
Assign the security policy to a port
Don’t forget to enable the Security Policy column
Create a firewall policy fromyour FortiLink interface to the RADIUS server that allows the RADIUS traffic
The FortiLink interface can only be assigned via the CLI
Optionally enable NAT on this policy if you want that, which I do
FortiAuthenticator RADIUS server
FortiAuthenticator user group
FortiSwitch security policy
FortiSwitch security policy assignment
FortiSwitch RADIUS policy
The 802.1X configuration the FortiGate uses is in the GitHub repository linked above.
With that completed, we can go back to the topic at hand.
Actually…
[Update 2026-06-07]: I have asked around a bit and got the feedback that the user certificate should not be removed if the client goes offline, which is also the experience I’ve had in the past. I have checked the release notes for EMS and FortiClient and couldn’t find anything that would hint at a change in behaviour or a bug, and I also updated FortiClient from 7.4.6 to 7.4.7, but this didn’t change anything. The user certificate continues to get removed shortly after I take the client offline. I will follow this topic, and hopefully I get to the bottom of it, because making 802.1X authentication possible in an easy manner would be great. In the meantime, you can test this behaviour yourself, and maybe you have a different experience. I can’t rule any configuration issues out on my side.
[Update 2026-06-08]: I have found the root cause of the behaviour where FortiClient deletes the user certificate. The reason is that when writing this post, I used the CN EMS_SUBCA for the EMS sub CA. FortiClient’s FortiESNAC process, however, has a certificate check that runs in some situations, like when the client goes offline, and this check inspects the issuing CA for the user certificate and if the CN does not match the serial number of EMS, the user certificate gets deleted. Practically, this means that if you create your custom CA, the CN needs to match the EMS serial number. I have put this information further up, so people start with the correct information.
We can see this behaviour in the FortiESNAC_1.log file (lots of lines in between are removed). I am presenting a case here where the serial number is not present in the CN.
FortiESNAC tries to find a certificate that has been issued by a CA with the EMS serial number as the CN, and if it doesn’t match, we see that the GetClientCertificate function fails:
Could not find the desired certificate FCTEMSSERIAL
FortiESNAC removes the unmatched certificate, which is the user certificate that was already deployed
A new ZTNA client certificate gets requested (Requesting new ZTNA client certificate: client certificate cannot be found on the system)
EMS cannot be contacted for this, because the client is offline (Could not contact the current server - backing up server address and trying other available servers)
With this information in mind, you can ignore the rest of this section, which I have marked for you.
IGNORE BLOCK STARTS HERE
I am so sorry for the detours, but it would be remiss of me not to mention the next part.
One thing rears its ugly head when it comes to 802.1X and using the EMS user certificates, which is the fact that FortiClient will delete the user certificate if it loses the EMS connection. I was able to narrow it down to the Online/Offline status displayed on FortiClient, and in my testing, this status change took about 10 seconds.
This behaviour leads to the problem that user authentication for 802.1X cannot really work because, by definition, you don’t have network access before authentication and you can’t get the Online status.
As a workaround, I (ab)used the guest VLAN function in the security policy. The idea is that a client fails, or doesn’t even attempt, the 802.1X authentication, falls back to MAB, which also fails, assuming you haven’t configured MAB, and arrives in the guest VLAN after some time. This VLAN allows a connection to EMS to get a certificate, and the client can complete the 802.1X authentication with this certificate.
You can, of course, configure MAB as a fallback.
You have to configure the following things for this:
Create a guest VLAN with all the necessary components, i.e. DHCP, DNS, etc.
Enable the Guest VLAN option in the FortiSwitch Security Policy
Set the Guest authentication delay to something reasonable
Enable MAC authentication bypass
Create a firewall policy allowing the necessary traffic
At a minimum, guests need to reach EMS over TCP/8013
Keep DNS in mind
Once the client gets the certificate, he can, at least on Windows, easily sign in using the provided pop-up.
FortiSwitch guest security policy
FortiGate guest policy
Missing certificate on Windows
Windows sign-in
This works for wired 802.1X. Wireless is another beast, and I got nothing here.
Sorry, not everything is a winner. Maybe someone can help me in this regard.
IGNORE BLOCK ENDS HERE
For 802.1X with EAP-TLS
Note: Much like with the VPN part, you cannot use the user certificate in a pre-logon scenario. You need a machine/computer certificate for that.
802.1X with EAP-TLS really is the thing you want to secure your network access in the physical space, and thanks to the work we already did, all the pieces exist to make it a reality.
FortiAuthenticator acts as the RADIUS/AAA server, the client has a user certificate and the CA certificates installed, and so we just need a layer 2 device to act as the NAS, and for me, this role is fulfilled by a FortiSwitch and a FortiAP.
How to configure 802.1X with EAP-TLS was already handled, so here are just some additional points:
In the RADIUS policy on FortiAuthenticator, only certificates issued by the EMS_SUBCA are accepted, because those are the certificates the client presents
If your client is validating the RADIUS server certificate, you have to make sure that it uses the correct CA certificate for this. In my case, the server certificate comes from the FAC_ROOT CA
The two points already mentioned are important if you are using Windows group policies for this, because you want/need to restrict both the server validation CA, as well as the issuing CA, for simple certificate selection
With a correctly configured client, we can check the 802.1X authentication state. First, wired, using the diagnose switch-controller switch-info 802.1X S424ENTFSERIAL portX command:
For Wi-Fi connections, we can also see, on the CLI, that there is an authenticated user available using the diagnose firewall auth list command.
diagnose firewall auth list
10.100.0.4, F3AB0220E981419CAA9A13CE4811E3F9
type: other, id: 0, duration: 204, idled: 123
flag(10): radius
server: FAC
packets: in 83 out 83, bytes: in 13099 out 7500
Again, here are the boring FortiAuthenticator RADIUS debugs:
Something on the side that cost me more time than I want to admit: I was using an out-of-band connection for the Windows 11 client and was connected via RDP to it so I could test the wired 802.1X configuration from a different room, and I constantly got authentication failures displayed on the NIC. I checked the Windows Event Viewer and saw the following error message on every authentication attempt:
Event ID 15514
Wired 802.1X Authentication failed.
Network Adapter: Intel(R) Ethernet Connection (6) I219-V
Interface GUID: {c8f64a9d-a891-4332-9e1e-1430095beadf}
Peer Address: 000000000000
Local Address: A4BB6D13072B
Connection ID: 0xf
Identity: -
User: -
Domain: -
Reason: 0x50001
Reason Text: Unable to identify a user for 802.1X authentication
Error Code: 0x525
I looked for a long time to find out why a user couldn’t be found for 802.1X authentication, because, in theory, everything is in order. I then physically went to the client, and the authentication immediately worked. I went back to my office, connected via RDP and was met with the same failure message. Turns out that 802.1X EAP-TLS with user authentication doesn’t work when you are connected via RDP. Maybe there is a setting somewhere that allows this, but it’s such an edge case that I didn’t want to spend more time on it.
[Update 2026-06-19]: I recently found the reason, and 802.1X user authentication not working in an RDP session is expected and documented.
For mobile devices
If you have managed mobile devices, you might be able to use the EMS MDM Integration option to let your Android or iOS devices get a certificate from EMS if they are managed by one of the supported options.
In such a scenario, EMS acts as an SCEP server, reachable over TCP/4001 and TCP/4002, where mobile devices can request a certificate.
Once you have the certificate, you can do much the same as with regular clients, so VPN and 802.1X.
DPI is also possible, of course. If you can install the CA using your MDM, that saves you from having to do it manually using the certificates FortiClient gets from EMS.
Covering this topic could be a post in itself, so I will leave you with the documentation for now.
Using EMS user certificates outside of their intended use, that is, ZTNA connections, is a niche use case, and provisioning certificates using other methods can be a better option, but if it fits your use case, this is a good alternative. The idea for this post actually came from a talk I had with Manuel Lehner from Fortinet at an event. He brainstormed some stuff with me, and I thought the topic was interesting enough to see how everything behaves. I am glad that I did, because I definitely learned something from this, and I hope you, dear reader, were able to take something of value with you while reading this.
Back when I initially published my FortiGate best practices baseline, I had already planned to create configurators to more easily deploy what the baseline recommends. Due to time constraints, this wasn’t possible, however. I have now gotten around to taking the first step by publishing the FortiGate Ansible best practices baseline configurator on GitHub. The README.md on GitHub should be enough to get someone familiar with Ansible started using the configurator.
The configurator takes care of most of the baseline, but some topics cannot be covered, either because of limitations of the Ansible modules or because additional information is required. These things have to be handled manually, but I am still happy with what I was able to accomplish so far with it.
If you, dear reader, have any input for the configurator or the baseline in general, please don’t hesitate to bring it up. The best way is to create an issue on GitHub.
Looking ahead
As I wrote in the GitHub project README.md, more configurators are planned, but that will take some more time. For now, dear reader, please check out what the Ansible configurator can do.
A FortiGate comes with several tools built in to help us with automation and responding to events. In this post, I want to explore these options a bit more and show what you can do with them.
So, dear reader, let’s see what we can do with these tools!
The setup:
2x FortiGate 70G running 7.6.6
A FortiAnalyzer VM running 7.6.6
Automation stitches
The biggest, most powerful, and most dynamic option a FortiGate offers are automation stitches. You combine actions, which do something, and triggers, which respond to something, in a stitch, where you handle the logic and the flow, like sequential and parallel execution of actions and delays.
Before I get more into this part, please check out Yuri Slobodyanyuk’s blog post on automation stitches because he already did a lot of work on this topic.
Automation actions
Actions in stitches are the things actually being done. You can send notifications to Slack, Teams, send mails, trigger functions in cloud environments, execute CLI scripts, or execute webhooks.
There isn’t much to say about actions because they are rather self-explanatory, but one thing I want to mention is that CLI scripts are limited to 1023 characters, and you can use some variables in them.
Automation triggers
Triggers are the second thing we need to care about, because if we don’t know what event interests us, we won’t do anything in response.
FortiOS comes with some default triggers, like Configuration Change, HA Failover, and Conserve Mode, but I will focus on FortiOS Event Log, FortiAnalyzer Event Handler, and Incoming Webhook, because they offer the most flexibility.
FortiOS Event Log triggers
You cannot trigger on every event log, but the ones we can choose from are vast and knowing how to work with them is important.
Let’s take a look at two practical examples.
Example: Link monitor events
First, assume that you are monitoring a server using a link monitor and you need to respond to it being down, like enabling a static route.
The Link monitor status event produces the following log entry:
date=2026-05-02 time=16:19:29 eventtime=1777738768978397230 logid="0100022922" type="event" subtype="system" level="notice" vd="root" logdesc="Link monitor status" name="WIN-AD" msg="Link monitor state is changed from 0 to 1, please check if this triggers HA failover."
In this case, it is a down event (changed from 0 to 1). There is also a Link monitor status warning event, which makes this particular scenario clearer and easier to work with, but I won’t focus on that for a reason I will get to later.
In the log details, we can see the message (msg) and the link monitor name (name). This information is enough to build an automation trigger specific to this link monitor using Field filters.
config system automation-trigger
edit "LINK-MONITOR-DOWN"
set event-type event-log
set logid 22922
config fields
edit 1
set name "name"
set value "WIN-AD"
next
edit 2
set name "msg"
set value "*from 0 to 1*"
next
end
next
end
The wildcard symbol * does the magic here, because it allows us to focus on only what we need in the message and the name field covers the specific link monitor.
Use the trigger in a stitch and add whatever action you need. You can also build the reverse trigger, i.e. a link monitor coming alive, using the same event and name, but the message *from 1 to 0*.
The reason I am not using the Link monitor status warning event here is that a dead link monitor uses the warning event (ID 0100022932) in addition to the Link monitor status event (ID 0100022922). A link monitor coming alive uses only Link monitor status (ID 0100022922). I don’t want to use two different IDs for one process (link monitor alive and dead), but you can choose to do that.
Here are all the log messages for these scenarios:
date=2026-05-02 time=16:19:41 eventtime=1777738780479592552 logid="0100022922" type="event" subtype="system" level="notice" vd="root" logdesc="Link monitor status" name="WIN-AD" msg="Link monitor state is changed from 1 to 0, please check if this triggers HA failover."
date=2026-05-02 time=16:19:41 eventtime=1777738780479544752 logid="0100022922" type="event" subtype="system" level="notice" vd="root" logdesc="Link monitor status" name="WIN-AD" interface="undefined" probeproto="ping" msg="Link Monitor changed state from dead to alive, protocol: ping."
date=2026-05-02 time=16:19:29 eventtime=1777738768978397230 logid="0100022922" type="event" subtype="system" level="notice" vd="root" logdesc="Link monitor status" name="WIN-AD" msg="Link monitor state is changed from 0 to 1, please check if this triggers HA failover."
date=2026-05-02 time=16:19:29 eventtime=1777738768978357571 logid="0100022932" type="event" subtype="system" level="warning" vd="root" logdesc="Link monitor status warning" name="WIN-AD" interface="undefined" probeproto="ping" msg="Link Monitor changed state from alive to dead, protocol: ping."
Example: BGP neighborship events
The second example is around BGP neighborships. Assume that you want to create a trigger for a specific BGP neighbor going down and enable a static route if such an event happens. If we look at the logs for a BGP neighborship down event, we see the following:
In this case, we only have the message to work with, so a trigger to respond to such an event can look like this:
config system automation-trigger
edit "BGP-DOWN"
set event-type event-log
set logid 20300
config fields
edit 1
set name "msg"
set value "*neighbor 198.51.100.1 Down*"
next
end
next
end
A full debug of the event being generated and the automation stitch firing can be seen here (in my case, I am simply disabling port2):
BGP down event debug
autod(pid:1622) log packet: total sz:470 data sz:230 fld_num:8
autod(pid:1622) log datetime: 2026-05-01 11:56:27
autod(pid:1622) log header: logid:20300 vfid:0 sever:4 cat:1 subcat:3 key:0 flags:0404 reqlen:110 timestamp:1777636586845355006
fields:
id:10 name:(9)eventtime value:(19)1777636586845355006
id:2 name:(5)logid value:(10)0103020300
id:3 name:(4)type value:(5)event
id:4 name:(7)subtype value:(6)router
id:5 name:(5)level value:(7)warning
id:6 name:(2)vd value:(4)root
id:38 name:(7)logdesc value:(27)BGP neighbor status changed
id:24 name:(3)msg value:(80)BGP: %BGP-5-ADJCHANGE: VRF 0 neighbor 198.51.100.1 Down BGP Notification FSM-ERR
pid:1622-__subscr_close_cur_pkg()-141: close package size:1536 logs:2
__action_cli_script_open()-169: cli script action:PORT2-DOWN is called. svc ctx:0x559d57c000
accprof:super_admin script:
config system interface
edit port2
set status down
next
end
__read_cli_script_result()-117: cli script:
autod.10
output:
========== #1, 2026-05-01 11:56:27 ==========
70G-02 config system interface
70G-02 (interface) edit port2
70G-02 (port2) set status down
70G-02 (port2) next
70G-02 (interface) end
======= end of #1, 2026-05-01 11:56:27 ======
__action_cli_script_close()-219: cli script action is done. script:
config system interface
edit port2
set status down
next
end
output:
========== #1, 2026-05-01 11:56:27 ==========
70G-02 config system interface
70G-02 (interface) edit port2
70G-02 (port2) set status down
70G-02 (port2) next
70G-02 (interface) end
======= end of #1, 2026-05-01 11:56:27 ======
pid:1622-__handle_msg()-428: Subscriber:4 received package. pubid:0 pkgid:1206 pkg_index:0
pid:1622-__handle_pkg_logs()-370: Subscriber:4 processing package size:2253 logs:3 pickup:3
autod(pid:1622) log packet: total sz:1194 data sz:252 fld_num:12
autod(pid:1622) log datetime: 2026-05-01 11:56:28
autod(pid:1622) log header: logid:46600 vfid:0 sever:5 cat:1 subcat:0 key:0 flags:0484 reqlen:131 timestamp:1777636587533074284
fields:
id:10 name:(9)eventtime value:(19)1777636587533074284
id:2 name:(5)logid value:(10)0100046600
id:3 name:(4)type value:(5)event
id:4 name:(7)subtype value:(6)system
id:5 name:(5)level value:(6)notice
id:6 name:(2)vd value:(4)root
id:38 name:(7)logdesc value:(27)Automation stitch triggered
id:321 name:(6)stitch value:(8)BGP-DOWN
id:322 name:(7)trigger value:(8)BGP-DOWN
id:377 name:(12)stitchaction value:(10)PORT2-DOWN
id:226 name:(4)from value:(3)log
id:24 name:(3)msg value:(29)stitch:BGP-DOWN is triggered.
autod(pid:1622) log packet: total sz:626 data sz:298 fld_num:14
autod(pid:1622) log datetime: 2026-05-01 11:56:28
autod(pid:1622) log header: logid:44547 vfid:0 sever:6 cat:1 subcat:0 key:0 flags:0404 reqlen:180 timestamp:1777636587767735898
fields:
id:10 name:(9)eventtime value:(19)1777636587767735898
id:2 name:(5)logid value:(10)0100044547
id:3 name:(4)type value:(5)event
id:4 name:(7)subtype value:(6)system
id:5 name:(5)level value:(11)information
id:6 name:(2)vd value:(4)root
id:38 name:(7)logdesc value:(27)Object attribute configured
id:57 name:(2)ui value:(11)auto-script
id:12 name:(6)action value:(4)Edit
id:59 name:(6)cfgtid value:(9)600113152
id:62 name:(7)cfgpath value:(16)system.interface
id:63 name:(6)cfgobj value:(5)port2
id:64 name:(7)cfgattr value:(16)status[up->down]
id:24 name:(3)msg value:(27)Edit system.interface port2
autod(pid:1622) log packet: total sz:433 data sz:193 fld_num:8
autod(pid:1622) log datetime: 2026-05-01 11:56:28
autod(pid:1622) log header: logid:32549 vfid:0 sever:6 cat:1 subcat:0 key:0 flags:0404 reqlen:67 timestamp:1777636587778632118
fields:
id:10 name:(9)eventtime value:(19)1777636587778632118
id:2 name:(5)logid value:(10)0100032549
id:3 name:(4)type value:(5)event
id:4 name:(7)subtype value:(6)system
id:5 name:(5)level value:(11)information
id:6 name:(2)vd value:(4)root
id:38 name:(7)logdesc value:(29)Autoscript stop automatically
id:24 name:(3)msg value:(37)script autod.10 stopped automatically
You can also create a reverse trigger and use the BGP neighbor up message.
Here are all the log messages for these scenarios:
config system automation-trigger
edit "BGP-UP"
set event-type event-log
set logid 20300
config fields
edit 1
set name "msg"
set value "*neighbor 198.51.100.1 Up*"
next
end
next
end
Incoming Webhook
This option is powerful, but boring. You create the trigger, and you get a URL to send HTTP POST requests to. Moving on…
An automation debugging aside
If you ever need to debug automation stitches, you do that using the autod application and a triggered stitch looks like this:
Automation stitch executed
__action_cli_script_open()-169: cli script action:PORT2-DOWN is called. svc ctx:0x55a8894cf0
accprof:super_admin script:
config system interface
edit port2
set status down
next
end
__read_cli_script_result()-117: cli script:
autod.0
output:
========== #1, 2026-05-02 17:28:27 ==========
70G-02 config system interface
70G-02 (interface) edit port2
70G-02 (port2) set status down
70G-02 (port2) next
70G-02 (interface) end
======= end of #1, 2026-05-02 17:28:27 ======
__action_cli_script_close()-219: cli script action is done. script:
config system interface
edit port2
set status down
next
end
output:
========== #1, 2026-05-02 17:28:27 ==========
70G-02 config system interface
70G-02 (interface) edit port2
70G-02 (port2) set status down
70G-02 (port2) next
70G-02 (interface) end
======= end of #1, 2026-05-02 17:28:27 ======
Some more options regarding debugging are in Yuri’s blog post (again, check it out), like testing, statistics, etc.
One thing I think is interesting is the diagnose test application autod 1 option. In Yuri’s blog, the description is “Enable automation stitches logging.” but the description of the option is actually “Enable/disable log dumping”, and this really does mean that. It dumps all logs straight to the CLI.
Turn it on, and you get a live feed of every log being created, and since not only the examples of link monitors or BGP events create log entries, but also configuration changes, we can get some nice information quickly. Here is the output of a new firewall policy being created, as well as the regular log entry, for example:
The FortiOS Event Log triggers are nice, but they aren’t flexible. If there is no log for the scenario you need, if you need something more specific, or if you need some logic, they hit their limit. FortiAnalyzer takes care of this. Now, this is not strictly “onboard” because you do need another product, but I would be remiss not to mention it.
If you integrate FortiAnalyzer as a Fabric Connector, you can leverage the Event Handlers you create on FortiAnalyzer as a trigger on a FortiGate. Let’s take a practical example.
FortiAnalyzer comes with the Default-Brute-Force-Account-Login-Attack-FGT event handler, which does exactly what it says. It recognizes brute force administrator logins on a FortiGate. It uses a correlation sequence, where failed logins are checked, and if they aren’t followed by a successful login after 5 minutes, the event triggers.
If we want to use this event handler on our FortiGate, we clone it, because event handlers of the Built-in origin cannot be used for this, enable the Automation Stitch option, change the Threshold Duration to be the same as the NOT_FOLLOWED_BY value and then we go onto the FortiGate and create our trigger.
config system automation-trigger
edit "FAZ-BRUTE-FORCE"
set event-type faz-event
set faz-event-name "Custom-Brute-Force-Account-Login-Attack-FGT"
next
end
Debugging this communication is done on FortiAnalyzer using the oftpd application, like such (192.168.1.201 is my FortiGate):
There isn’t a lot going on here that we haven’t already seen, but we know it’s there.
A FortiGate dynamically pulls the available event handlers every time you try to select one in the trigger, by the way. This is done using an OFTP JSONRPC request. This also shows up on FortiAnalyzer in the oftpd debugs:
With FortiAnalyzer Event Handlers, the sky really is the limit. You can do practically everything your heart desires if you speak FortiAnalyzer.
Auto-scripts
The next automation option is auto-scripts, which are CLI-only in config system auto-script, and not that well-known, but they are powerful. Just look at the options:
70G-02 (LAB-SCRIPT) # set
interval Repeat interval in seconds.
repeat Number of times to repeat this script (0 = infinite).
start Script starting mode.
script List of FortiOS CLI commands to repeat.
output-size Number of megabytes to limit script output to (10 - 1024, default = 10).
timeout Maximum running time for this script in seconds (0 = no timeout).
The script option takes whatever FortiOS CLI commands you want, but there is a limit of 1023 characters, so you won’t be able to import certificates.
You can set the interval, how often the script runs and if it should start automatically or be executed manually.
If you ever need to escape some characters in the script, like double quotes (”), you can do that using backslashes \ as can be seen here:
config system auto-script
edit "LAB-SCRIPT"
set start auto
set script "config firewall address
edit \"TEST\"
set subnet 192.0.2.0/24
set comment \"Escape double quotes\"
next
end
"
next
end
Auto-scripts have the nice advantage of being able to be created by outside systems but run with local privileges on a FortiGate, which becomes important if you try to automate things that outside systems aren’t allowed to do. A good example is FortiManager. FortiManager cannot change some options on a FortiGate, like certain options related to HA or the central management options itself, meaning FortiManager cannot change the FortiManager options on a FortiGate.
To get around these limitations, you can create an auto-script on FortiManager and change whatever you need in that.
Importantly, auto-scripts can be created and controlled in automation actions, which opens up a lot of possibilities.
One use case for auto-scripts I want to highlight is regarding the execute auto-script result option. Since this command shows the result of an auto-script and auto-scripts can run diagnose and get commands, you can use this as a crude information storage.
Imagine that you run into the dreaded conserve mode, and you want to gather some information during that time, like which process took the memory. FortiGates come with a Conserve Mode automation trigger, and if you create the following automation action, you can later check what the status of your processes was using diagnose sys top 1 99 1 when conserve mode was triggered.
config system auto-script
edit "LAB-SCRIPT-TOP"
set script "diagnose sys top 1 99 1"
next
end
The reason I am performing an execute auto-script start LAB-SCRIPT-TOP after creating the auto-script is because setting set start auto is, for some reason, not possible with an automation action.
========== #1, 2026-05-01 12:39:06 ==========
70G-02 config system auto-script
70G-02 (auto-script) edit "LAB-SCRIPT-TOP"
70G-02 (LAB-SCRIPT-TOP) set start auto
Script start-mode could not be changed from auto-script.
node_check_object fail! for start auto
value parse error before 'auto'
Command fail. Return code -37
70G-02 (LAB-SCRIPT-TOP) set script "diagnose sys top 1 99 1"
70G-02 (LAB-SCRIPT-TOP) next
70G-02 (auto-script) end
70G-02 execute auto-script start LAB-SCRIPT-TOP
======= end of #1, 2026-05-01 12:39:06 ======
Once your conserve mode problem has been solved, or it has solved itself, run an execute auto-script result LAB-SCRIPT-TOP and check your output.
Auto-script output
70G-02 # execute auto-script result LAB-SCRIPT-TOP
Script LAB-SCRIPT-TOP output:
========== #1, 2026-05-02 18:13:44 ==========
70G-02 diagnose sys top 1 99 1
06:13:46 PM up 0 days, 2 hours and 21 minutes
0U, 0N, 0S, 100I, 0WA, 0HI, 0SI, 0ST; 3708T, 2315F
miglogd 1714 S 0.5 1.3 2
node 1572 S 0.0 2.6 3
ipsengine 1893 S 0.0 2.1 3
ipshelper 1604 S 0.0 2.1 2
ipsengine 1895 S 0.0 2.1 2
ipsengine 1894 S 0.0 2.1 0
wad 1665 S 0.0 1.6 3
wad 1663 S 0.0 1.5 1
cmdbsvr 1484 S 0.0 1.5 0
cw_acd 1614 S 0.0 1.3 2
forticron 1558 S 0.0 1.3 0
extenderd 1631 S 0.0 1.2 1
miglogd 1570 S 0.0 1.2 0
csfd 1634 S 0.0 1.2 3
wad 1664 S 0.0 1.2 0
newcli 5113 S 0.0 1.2 3
newcli 2866 S 0.0 1.2 2
httpsd 5707 S 0.0 1.1 1
wad 1656 S 0.0 1.1 0
fgfmd 1613 S 0.0 1.1 2
initXXXXXXXXXXX 1 S 0.0 1.1 3
wad 1580 S 0.0 1.0 1
http_authd 1551 S 0.0 1.0 2
wad 1659 S 0.0 1.0 0
wad 1657 S 0.0 0.9 2
iked 1718 S 0.0 0.9 3
wad 1661 S 0.0 0.9 1
wad 1662 S 0.0 0.8 0
fgtlogd 1593 S 0.0 0.8 3
wad 1655 S 0.0 0.8 0
wad 1660 S 0.0 0.8 1
wad 1658 S 0.0 0.8 2
authd3 1726 S 0.0 0.8 1
csfd 0 1964 S 0.0 0.8 3
authd2 1725 S 0.0 0.8 3
eap_proxy_worke 1728 S 0.0 0.8 3
iked 1716 S 0.0 0.8 3
telemetryd 1633 S 0.0 0.8 1
iked 1717 S 0.0 0.7 0
fcnacd 1567 S 0.0 0.7 0
dnsproxy 1617 S 0.0 0.7 3
forticldd 1559 S 0.0 0.7 1
cu_acd 1623 S 0.0 0.7 0
httpsd 1550 S 0.0 0.7 2
imi 1546 S 0.0 0.7 3
bgpd 1543 S < 0.0 0.7 2
nsm 1542 S 0.0 0.7 3
updated 1589 S 0.0 0.7 0
authd 1560 S 0.0 0.7 2
fltund 1626 S 0.0 0.6 0
snmpd 1596 S 0.0 0.6 3
cid-config 1610 S N 0.0 0.6 2
fnbamd 1557 S 0.0 0.6 3
fortimq 1637 S 0.0 0.6 3
iotd 1598 S 0.0 0.6 1
voipd 1579 S 0.0 0.6 0
foauthd 1561 S 0.0 0.6 1
sshd 1600 S 0.0 0.6 3
wpad_ac 1618 S 0.0 0.6 2
ntpd 1599 S < 0.0 0.6 2
flcfgd 1624 S 0.0 0.6 2
http_authd 1554 S 0.0 0.5 2
radvd 1603 S 0.0 0.5 2
iked 1715 S 0.0 0.5 0
forticron 5778 S 0.0 0.5 2
lnkmtd 1601 S 0.0 0.5 3
autod 1635 S 0.0 0.5 2
zebos_launcher 1527 S 0.0 0.5 3
cloudapid 1636 S 0.0 0.5 3
sshd 2865 S 0.0 0.4 3
syslogd 1591 S 0.0 0.4 3
locallogd 1595 S 0.0 0.4 2
forticron 5779 R 0.0 0.4 3
forticron 5777 S 0.0 0.4 1
cid-scan 1620 S 0.0 0.4 0
fortilinkd 1622 S 0.0 0.4 2
vwl 1602 S 0.0 0.4 2
ikecryptd_dhw0 1583 S 0.0 0.4 3
ikecryptd_dhw2 1585 S 0.0 0.4 3
ikecryptd_dhw1 1584 S 0.0 0.4 3
ipsmonitor 1555 S 0.0 0.3 1
flpold 1625 S 0.0 0.3 2
cw_acd_helper 1733 S 0.0 0.3 2
dnsproxy 1606 S 0.0 0.3 1
ipmc_sensord 1548 S 0.0 0.3 1
dhcp6c 1630 S 0.0 0.3 1
ikecryptd 1581 S 0.0 0.3 1
getty 1552 S < 0.0 0.3 0
alertmail 1605 S 0.0 0.3 3
eap_proxy 1611 S 0.0 0.3 3
merged_daemons 1556 S 0.0 0.3 2
clearpass 1563 S 0.0 0.3 3
uploadd 1547 S 0.0 0.3 2
fsso_ldap 1569 S 0.0 0.3 3
fas 1566 S 0.0 0.3 1
httpclid 1565 S 0.0 0.3 2
fsd 1629 S 0.0 0.3 1
kmiglogd 1549 S 0.0 0.3 3
getty 1553 S 0.0 0.2 3
======= end of #1, 2026-05-02 18:13:47 ======
The last topic for automation I want to write about is batch mode. This isn’t a way to automate something, but you can use it in automation, so it gets a spot.
Batch mode is something you start, write down your CLI commands, and once you end the mode, the commands get executed. This allows you to make lots of changes in a single “transaction”, not to be confused with the actual config transactions, where the individual configurations might cut off your access at some point.
Imagine you are managing a FortiGate via a VPN, and you need to drastically change the VPN configuration, which would normally disconnect you somewhere along the configuration. With batch mode, you can do this all without disruption.
One very nice thing about batch mode is that you don’t have to worry about formatting, character limits or anything of the sort. Since you’re working like normal on the CLI, you can do just about every configuration. In the auto-script section, I mentioned how the character limit is 1023, so importing certificates won’t work. This is no problem with batch mode.
You can, of course, use batch mode in combination with other automation options, like automation actions in stitches, but keep the 1023-character limit in mind.
In combination with automation actions, I want to note that batch mode does not display output of the command being executed, so if you do a get system status in batch mode, you don’t have any output of this directly. You have to do an execute batch lastlog to get the output.
A use case at the end: Push API feed
A little-known feature a FortiGate has is being able to host feeds itself using the Push APImethod. The idea is that you use API requests to send IPs, MAC addresses, domains, etc., to the FortiGate; it adds or removes the entries from the feed, and you use the feed wherever it makes sense, like in firewall policies.
One caveat is that the information in the feed is volatile and does not survive a reboot.
Let’s combine a few things from this post to create an example.
We start with our FortiAnalyzer Event Handler Custom-Brute-Force-Account-Login-Attack-FGT (this is the cloned event handler from above) as our trigger. If it gets triggered, we add the source IP from that event to the Push API feed using a webhook,ban the IP and also send an email notification with the log information.
Push API automation stitch configuration
config system automation-trigger
edit "FAZ-BRUTE-FORCE"
set event-type faz-event
set faz-event-name "Custom-Brute-Force-Account-Login-Attack-FGT"
next
end
config system automation-action
edit "PUSH-API-WEBHOOK"
set action-type webhook
set protocol https
set uri "192.168.1.201/api/v2/monitor/system/external-resource/dynamic"
set http-body "{ \"commands\": [{ \"name\": \"PUSH-FEED\", \"command\": \"add\", \"entries\": [ \"%%log.srcip%%\" ] } ]}"
set port 8443
config http-headers
edit 1
set key "Authorization"
set value "4xdzrjdhdrs3417b51wqt4cw05p0rd"
next
end
set verify-host-cert disable
next
end
config system automation-stitch
edit "FAZ-BRUTE-FORCE-PUSH-API"
set status disable
set trigger "FAZ-BRUTE-FORCE"
config actions
edit 3
set action "IP Ban"
set required enable
next
edit 2
set action "PUSH-API-WEBHOOK"
set required enable
next
edit 4
set action "Email Notification"
set required enable
next
end
next
end
Wrapping up
I realize that nothing I covered in this post is that advanced, but I saw some of the things I covered being asked a few times, and some things, like the log dumping, Push API feeds, and auto-scripts as information storage, are things I personally wanted to look at a bit more, and I turned it into a post.
So, dear reader, thank you for your time in indulging me.