As Fortinet has written in their 8.0 New Features guide, the Zero Trust Network Access configuration got an overhaul and was simplified. To get used to the new way, I went through all the ways you can configure ZTNA, as well as integrate it with Entra SAML SSO. The fruits of my labour, dear reader, can be seen in this video:
…but wait, there’s more!
Just a video alone is boring, so here is some more information:
The default minimum TLS/SSL version of the ZTNA VIP is still set to 1.1, same as always. Vulnerability scanners usually don’t like that, so you can set this to something better, like 1.2.
config firewall vip
edit "ZTNA-PORT-9443"
set ssl-min-version tls-1.2
next
end
Before FortiOS 8.0, setting the vhost/host for a Traffic Forwarding server was optional, which led to the problem of the FQDN value in EMS constantly resetting if you made certain changes and you didn’t set the value yourself beforehand. With 8.0, you have to set this value when creating a Traffic Forwarding server, which creates a better administrative and user experience.
The synchronization time between FortiClient and FortiClient EMS is at a default of 60 seconds for on-prem and 300 seconds for EMS Cloud. You can lower this to a minimum of 20 seconds by changing the Keepalive Interval value in System Settings -> EMS Settings -> Endpoint Settings
While you can use regular firewall policies as ZTNA policies, instead of proxy policies, I wouldn’t recommend it. I ran into weird issues if I didn’t use proxy policies, and the recommendation by Fortinet is also to use proxy policies.
Afterthoughts
I not only made this video to get used to the new way. I also needed an excuse to finally set up my Entra tenant and work on my video editing and recording skills. I have learned a few non-Fortinet things in this process, and I’m glad I went through it.
So, dear reader, hopefully you can get something out of this post. Maybe not now, but when 8.0 is a recommended version.
Not every post needs to be something that takes hours to get together, and that is why I want to start a new type of post, where I write about things I experience in my day-to-day, that seem interesting, and want to put out there, so maybe someone else doesn’t have to spend hours looking for a solution.
With that, dear reader, read about the quick tips for today.
Models and versions:
FortiGate 70G on 7.6.6
FortiSwitch 424E on 7.6.6
FortiClient EMS on 7.4.7
FortiClient on 7.4.6
Windows 11 client on 25H2
Handling double quotes in FortiSwitch custom commands
Custom commands are an important part of managing FortiSwitches, because not every piece of configuration you can make on a FortiSwitch is available on the FortiGate. Lots of things you don’t touch every day have to be configured using custom commands, and in some cases, you run into the issue of having to use double quotes, which creates an issue, because double quotes, on the FortiGate, signify where the custom command starts and where it ends.
Looking at the example that prompted this post: You want to push a Certificate Authority (CA) certificate to a FortiSwitch for LDAPS authentication.
On a FortiSwitch, this would look like this (certificate shortened for brevity):
config system certificate ca
edit WIN-CA
set ca "-----BEGIN CERTIFICATE-----
MIIFrDCCA5SgAwIBAgIQP+FZ4Onx66RLEDsn8YuCGTANBgkqhkiG9w0BAQ0FADBV
MRMwEQYKCZImiZPyLGQBGRYDY29tMRkwFwYKCZImiZPyLGQBGRYJbGFiZG9tYWlu
MRIwEAYKCZImiZPyLGQBGRYCYWQxDzANBgNVBAMTBldJTi1DQTAeFw0yNTA4MDIx
...
TETr69CP4eDwJGD7gZp8Lnz98Xj+fQUcco+/9xuK7JXQlE97H05Mn32YZmv4aFC0
v2S0t0Lk+YqUVZKAMRfnZ9nu8dTHFE4Q+5TqOxcyEwpmAeXRIWs/HPOXhegu87tu
bqYj4gD9n/S8BjlOqtx/Yw==
-----END CERTIFICATE-----"
next
end
The double quotes in the BEGIN CERTIFICATE line will create your first problem when using a custom command. If you want to push this certificate using a custom command on the FortiGate, you have to replace the double quotes with a % symbol followed by the hex code. In this case, it would be %22.
Using it in a custom command, we can deploy a CA certificate like this (certificate shortened for brevity):
config switch-controller custom-command
edit "WIN-CA"
set command "config system certificate ca%0a edit WIN-CA%0a set ca %22-----BEGIN CERTIFICATE-----%0aMIIFrDCCA5SgAwIBAgIn/S8BjlOqtx/Yw==%0a-----END CERTIFICATE-----%22%0a next%0a end%0a"
next
end
config switch-controller managed-switch
edit "SERIAL/NAME"
config custom-command
edit "WIN-CA"
set command-name "WIN-CA"
next
end
next
end
Someone hinted at the possibility of this by telling me to use a backslash and the hex code, which didn’t work, and then I remembered that in custom commands, %0a should be used for a line feed, and 0a is the hex code for that. Put two and two together, and you end up with %22.
Thank you to this specific person, who, I am sure, wants to remain anonymous, but he will likely read this.
ZTNA “failed to match an API-gateway” error using SAML SSO authentication
If you’re configuring SAML SSO authentication for your ZTNA connections and you run into the issue where FortiClient displays a “The page you requested has been blocked because no API gateway was matched” and the FortiGate shows an error like “Traffic denied because HTTP url (https://ztnaproxy.domain.com/tcp?address=win-ad.ad.labdomain.com&port=3389&tls=0) failed to match an API-gateway with vhost(name/hostname:saml_auto_vhost_SAML/ztnaproxy.domain.com)” (the FQDN and the “SAML” is custom and will look different for you), I might have an answer for you.
Go into your ZTNA server configuration and the service/server mapping, specify the virtual host, supply a host matched by a substring that corresponds to the domain from the HTTPS URL in the error, and select your certificate.
config firewall access-proxy-virtual-host
edit "auto-ztna-vhmoifbct0"
set ssl-certificate "ztnaproxy.domain.com"
set host "ztnaproxy.domain.com"
next
end
config firewall access-proxy
edit "LAB-ZTNA"
set vip "LAB-ZTNA"
config api-gateway
edit 1
set url-map "/tcp"
set service tcp-forwarding
set virtual-host "auto-ztna-vhmoifbct0"
config realservers
edit 1
set address "win-ad.ad.labdomain.com"
set mappedport 3389
next
edit 2
set address "ubuntu-ws-1.ad.labdomain.com"
set mappedport 80
next
end
next
edit 2
set service samlsp
set saml-server "ENTRA-SAML"
next
end
next
end
I’m not sure why this error happened, because I have configured this thing before without having to specify a virtual host, but this works.
[Update 2026-04-29]: Yesterday, in bed, I found the cause of this behaviour change. Since 7.6.1 FortiOS learns, and creates, a virtual host implicitly from a SAML authentication when using ZTNA. The proposed workaround of “To avoid this behavior, put the SAML api-gateway in a separate access-proxy and use a different virtual host for your server.” is a bit confusing to me, but unless you have multiple SAML servers for ZTNA, which creates other issues due to authentication rules, it’s not relevant. If you have multiple SAML servers, you will always fall into the authentication rule that is first in the list, unless you configure source and/or destination addresses (CLI-only), so you can authenticate with that one, and you get a user on the FortiGate in WAD, but authorization will fail, because you are hitting a proxy rule with one SAML server, but you were authenticated with another.
Evaluating firewall sessions after dynamic address changes (ZTNA tags, FSSO)
After solving the error from the previous section, I played around with ZTNA tags and noticed that, if a tag changes, existing sessions won’t get evaluated and possibly blocked. This is something you have to be aware of. If you are using dynamic address objects, you probably want existing sessions to not transfer traffic if new ones should get blocked.
Think of an attacker establishing a channel from inside your network to an external command and control server, and if your ZTNA tags notice this, this connection won’t get blocked, because it’s an existing session.
If you want existing firewall policies to get evaluated if dynamic address objects change, be that ZTNA tags, FQDN objects, Fortinet Single Sign-On (FSSO) objects, or otherwise, it’s only a single command away.
config system settings
set dyn-addr-session-check enable
end
Short and sweet and done
I might do this type of post more often, and I hope that you, dear reader, can take something away from this.
A new major release of FortiOS is always an exciting time in the Fortinet ecosystem, because it gives the direction on where Fortinet wants to take Fortinet and, of course, FortiGate.
7.0 brought us the Zero Trust Network Access (ZTNA) features, for example, and that is still a big talking point.
I want to take this post to focus on new features, big and small, that I think are interesting, both from an operational viewpoint as someone in the field, as well as features Fortinet wants you to know about.
So, dear reader, join me in going through the depths of the FortiOS 8.0 New Features guide to find out what 8.0 has got to offer.
Note: Some features don’t have an entry in the New Features guide. In these cases, I will link to the release notes with the corresponding feature ID and, if available, supplemental documentation.
I am putting this one first, because its usefulness might otherwise slip through the cracks, and it would be a shame, because the feature is awesome and quite possibly my number 1 feature in 8.0.
At first glance, it just looks like you can attach the tag to things to make elements stand out, but where it shines is with address objects and address groups.
Imagine a scenario where you have a fleet of Linux servers that all need to get to a repository for updates. Previously, you had to create your servers as address objects, optionally add them to a group, and then use either the object itself or the group in a policy.
With tags, you can put the tag on the server address object, use the tag in an address group and use the group in a policy. Every additional Linux server gets an object with a tag, and you’re done. This makes the process more dynamic, and deleting individual objects is also easier, because there is no reference to a group.
I’m sure you can already think of some fantastic use cases for this, because I sure can.
Fortinet has a nice video demonstrating this, so I don’t have to make one.
Using these tags for policies is a nice bonus, but I don’t see that much use here outside of filtering. If you want to tag your policies, be sure to first enable the feature Policy Tagging in Feature Visibility.
This is a combined new feature, because it also requires FortiAnalyzer 8.0, but it’s too cool not to mention.
With fabric feeds, you can leverage the Fortinet Message Queue (FortiMQ) fabric to synchronize threat feeds across your fabric, with FortiAnalyzer being at the heart of it. On FortiAnalyzer, you can block indicators (IPs, domains, URLs, and MAC addresses), and these get synchronized to all FortiGates that are registered under the same FortiCloud account as fabric feeds.
These fabric feeds are configured in the same place as external feeds and act much the same way in how you handle and use them. Use IP and MAC feeds in policies, domains in DNS filters, and URLs in web filters as categories.
If you think this is useful, but don’t want to upgrade to 8.0, I understand your hesitation; you might be in luck. A very similar feature exists in 7.6, except it also requires FortiManager.
In this configuration, indicators are blocked on FortiAnalyzer; this information is sent to FortiManager using a playbook, and FortiManager hosts these indicators as threat feeds for FortiGates to consume.
I’m putting this feature here for the future, because as it stands, it’s something you should look forward to, but it isn’t all that useful right now.
IKE over TCP has been a thing for quite some time now, and while it works, it has the distinct downside of not being TLS traffic, meaning that application-based firewalls don’t recognize it as such, even if it uses TCP/443 as its port and protocol. This can be a problem for employees on the go, because wherever they are, their non-TLS application traffic might get blocked.
With this feature, the entire IKE communication gets wrapped in a nice TLS 1.3 bow and is sent out into the world, passing itself off as any other communication you might expect on a network that allows TCP/443.
This feature also fills the requirement of TLS-based VPN being available, which 7.6 lost with 7.6.3, since SSL-VPN was deprecated.
The reason I wrote that this feature is for the future is that, at the time of writing (April 2026), FortiClient does not support this. It should come with FortiClient 8.0, but right now, it’s only FortiGates that can use this feature.
Before 8.0, lots of HA processes were CLI-only, like resyncs, forced failovers, configuration difference checks, etc. With 8.0, you can perform these things directly in the GUI, which makes this topic more approachable.
You now also get a quick overview of some vital statistics, like RAM usage and checksums.
Note: The failover in the GUI uses the not-so-nice static and manual failover method under the hood. This means that if you want to perform a failback, you have to do so manually. Due to this, I would not recommend using it, except, as the link says, for testing and demonstrations. If you want to perform a lasting failover, continue using the diagnose sys ha reset-uptime method on the CLI, which assumes you are outside the ha-uptime-diff-margin window.
If you needed a lot of similar DHCP servers on your FortiGate, you previously had to create them individually per interface, and if you ever needed to make changes, like a new DNS or NTP server, you had to edit each entry. With DHCP templates, you can create one template entry and attach it to each interface. Pretty easy to see the value in this new feature.
In the GUI, you can find the DHCP Templates option in Network -> IPAM -> DHCP Templates. You can also create DHCP templates inline while configuring a DHCP server. You first have to enable the DHCP server option on the interface, then the DHCP template option, and in the dropdown, click on the + to get to the wizard. In the CLI, the configuration is under config system dhcp template.
Once there, you can set all options you normally can in the GUI as well. Some options, like the domain, continue to be CLI-only.
One thing I want to expand on here is how the Excluded ranges option works in the template, because, at least to me, it was not immediately obvious.
When you add an excluded range, you have a field titled Offset (start-ip-index in the CLI) and Number of addresses to reserve (ip-count in the CLI).
Offset in this case means at which IP does the excluded range start, and the number of addresses to reserve is the number of reserved IPs after, and including, the offset IP.
Let’s look at an example, using the DHCP template from above.
If you turn on the DHCP server debugs (diagnose debug application dhcps -1) on the CLI, we get the following output after enabling debugs and applying the template:
[debug]setting up address range 192.0.2.1 - 192.0.2.9
[debug]setting up address range 192.0.2.30 - 192.0.2.59
[debug]setting up address range 192.0.2.70 - 192.0.2.254
The first excluded range has the offset 10, so the first excluded IP is 192.0.2.10. The reserved addresses are set to 20, so, including .10, we have 20 reserved IPs, which gives us a range from 192.0.2.10 to 192.0.2.29.
The second excluded range has the offset 60, so the first excluded IP is 192.0.2.60. The reserved addresses are set to 10, so, including .60, we have 10 reserved IPs, which gives us a range from 192.0.2.60 to 192.0.2.69.
Everything outside the excluded ranges is fair game for DHCP leases.
SD-WAN member selection based on monthly traffic volume
A big benefit of SD-WAN on FortiGates is that you can use whatever as your local internet breakout, and lots of sites are using 5G/LTE modems, which usually have a monthly traffic limit. If the limit is reached, you are often capped on your speed or incur additional costs. Using this new feature, you can set various parameters regarding your monthly quota and what should happen if your quota is reached (increasing the cost if using a Lowest cost SLA, for example), so you can deprioritize the SD-WAN member.
SD-WAN on-demand duplication
Duplication, in relation to SD-WAN, is the act of sending duplicates of packets to another FortiGate to overcome link problems, like high latency or packet loss. Using this new feature, the hub in an SD-WAN deployment can enable duplication based on the received SLA information from a spoke.
The hub usually has a remote SLA, which measures the spoke’s connection status, using the spoke’s embedded SLA information in its ICMP probes towards the hub. If the spoke is out of SLA, the hub can now enable duplication dynamically to send duplicate packets, in the hope that this is enough to provide a good experience for users on the spoke’s site.
8.0 has overhauled how you configure ZTNA, both in the GUI and on the CLI. Fortinet calls it a simplification, and maybe this is true; only time will tell, but it’s a bit of a shakeup from before and will take some time to get used to.
Instead of, for example, creating, at least in the GUI, only a ZTNA server, which has everything in it, you now create:
A port, which holds the information on how your clients connect
A traffic forwarding server, which binds the port and is used in your policies
Destinations, which are the resources accessed using the traffic forwarding server and are also used in your policies
The decoupling of the traffic forwarding server and the destinations is definitely where I can see the greatest enhancement, because it promotes reusability instead of duplication.
[Update 2026-05-08]: Since writing this post, I have created a blog post and video about how to configure ZTNA on 8.0 with a Microsoft Entra integration for SAML SSO. I actually like the new way more.
A maybe unexpected big focus of 8.0 is post-quantum cryptography (PQC). PQC isn’t a new thing in FortiOS, with Post-Quantum Preshared Keys (PPK) having been implemented in FortiOS 7.2, and quantum-safe algorithms like ML-KEM and HQC being in 7.6, but 8.0 brings some more things.
Fortinet has its own Quantum Security Hub if you want to read more about this topic in relation to Fortinet solutions, but let’s look at practical things.
HTTPS and SSH management use quantum-safe algorithms
If you access the FortiGate GUI with a browser that uses quantum-safe algorithms, you will automatically use X25519MLKEM768 to connect. This behaviour, to my knowledge, cannot be changed in FortiOS, which is not a bad thing.
On the CLI, you can find mlkem768x25519-sha256 as a default SSH key exchange algorithm.
FGT-80-REVIEW(Primary) # get system ssh-config | grep ssh-kex-algo
ssh-kex-algo : diffie-hellman-group14-sha256 diffie-hellman-group16-sha512 diffie-hellman-group18-sha512 diffie-hellman-group-exchange-sha256 curve25519-sha256@libssh.org ecdh-sha2-nistp256 ecdh-sha2-nistp384 ecdh-sha2-nistp521 mlkem768x25519-sha256
If you perform deep inspection in a proxy-mode policy, connections are no longer downgraded to a non-hybrid PQC algorithm, bringing an immediate security benefit.
If you have used FortiManager or FortiAnalyzer in 7.6, you should be familiar with FortiAI-Assist, but Fortinet puts it best in explaining this new feature:
“FortiAI can answer technical questions by using the FortiOS documentation and troubleshoot issues by reading logs directly from FortiGate or by analyzing debug outputs provided by administrators. The CLI Code Lab tool helps administrators use natural language prompts to generate and execute complex FortiGate configurations.”
The option to choose between FortiAI and OpenAI as the provider is nice if you already have an OpenAI plan and don’t want to spend additional money on tokens, though, depending on your FortiCare support status, you get a good amount of FortiAI tokens per month.
Messages sent to FortiAI use data masking, so sensitive data, like IPs, email addresses, URLs, etc., is masked. The documentation doesn’t make it clear, but I assume it uses the same method as FortiAnalyzer and FortiManager and to-be-masked data is replaced with bogus information before being sent to the LLM and after receiving a response, the replacement is undone.
The new features entry gives some examples of what you can do, but let’s see what else it can do, like figuring out why a subnet cannot access the internet based only on a prompt (spoiler: I disabled SNAT on the policy).
Evidently, there are some issues with FortAI and accessing FortiGate configuration and diagnosis. Maybe these are guardrails, but for the time being, I have only executed the debug flow commands because FortiAI put an “especially” there.
If you click on the “Completed thought process” button, you can also see what steps are being performed in all of this.
Let’s give it the debug output and check again.
So it gave some good information here, but the important command, get firewall policy 1, does not work. Another try, and it delivered the correct command and also the solution.
As another example, let’s see how it handles local-in policy creation.
It took just a bit of help, but it got there. The logtraffic setting doesn’t exist, but I’m not going to hold it against it. In my prompt, you can also see that masked pieces of information are highlighted.
This is not an AI feature, but it was introduced with it, so I put it in this section to not spoil the surprise of FortiAI-Assist.
The CLI Code Lab is awesome. If tags are my number 1, this is my number 2. What the CLI Code Lab offers is a no-fuss method to access the stage-and-commit/transaction feature in FortiOS.
You write your configuration in the code lab, execute it, which creates a transaction, and then you can commit it. The great thing is that the transactions are not immediately being written to memory, i.e. the running configuration, so you can do several configurations and only commit them if you are happy with the outcome. This helps when you need to make changes that, if done individually, would cut off your access, for example. There are ways around that, like scripts, but the code lab gives immediate feedback if something wouldn’t work, and it’s not committed immediately.
Here is an example flow of me changing the hostname, it not immediately being reflected in the CLI, showing the transaction information, and verifying the new hostname on the CLI after commiting the changes.
FGT-80-REVIEW(Primary) # get system global | grep hostname
gui-display-hostname: disable
hostname : FGT-80-REVIEW
FGT-80-REVIEW(Primary) # diagnose sys config-transaction show txn-info
txn_id=4, expire=11 seconds, user='admin', userfrom='https(192.168.1.101)', clicmd_fpath='/dev/cmdb_priv/txn/4_ELjGss.conf'
FGT-80-REVIEW(Primary) # diagnose sys config-transaction show txn-cli-commands 4
config system global
set hostname "CLI-CODE-LAB"
end
COMMIT CHANGES IN THE GUI
CLI-CODE-LAB(Primary) # get system global | grep hostname
gui-display-hostname: disable
hostname : CLI-CODE-LAB
The way the code lab does its thing, i.e. using transactions, isn’t anything new. In the background, it uses workspace mode, introduced in 6.2, and the corresponding transaction process, but now we have a GUI way to use this feature, making it more approachable. Again. Awesome. One thing that I hope the code lab gets in the future is syntax autocomplete, like FortiManager.
Use AI and ML to detect malicious activity in DNS traffic
Model Context Protocol (MCP) is a protocol that connects AI applications to external systems, so that the individual applications don’t need individual methods to access external systems. The MCP server acts as a kind of translator so the two sides can communicate with each other.
The Agent2Agent (A2A) Protocol is used by AI agents to communicate with each other using a shared language.
FortiOS 8.0 can now detect applications using these protocols and also display detailed information about what is happening in these communications.
Imagine a developer using Visual Studio Code connected to an MCP server. If the developer interacts with the GitHub repository through the MCP server, the accessed files, issues, etc., can be recognized in logs now.
With the growing adoption of MCP, this functionality becomes more and more important.
Rapid-fire new features
Let’s hammer out some new features quickly, because I do believe they are important, but there isn’t much to say about these.
Managed FortiSwitch trusted hosts and Private Data Encryption
Both trusted hosts and Private Data Encryption aren’t new to FortiSwitchOS, but before 8.0, you had to painstakingly configure these manually on each FortiSwitch or by using custom commands. With FortiOS 8.0, you can now do this using the switch controller feature, making securing your FortiSwitches that much easier.
A feature I loved on Cisco ASAs, and missed on FortiGates, is knowing immediately, on the CLI, if the current device is a primary or a secondary. Now this is possible, and using get system status or get system ha status is no longer necessary for this.
Forward Traffic logs now display a summary panel in the details that gives you a quick overview of the most important things, like the policy ID, policy action and why something was blocked, including UTM profile and action. This feature got lost in a previous version (I want to say 7.2) due to other changes, and now we have it back.
You can now restrict certain administrators from using specific login methods. This feature is especially useful because you can restrict console logins, meaning that you can make a break-glass administrator account that can only use the console and disallow it for all others.
Managed FortiAPs, by default, send their data traffic in cleartext. Previously, if you enabled a secure channel for this type of traffic, it wasn’t offloaded. With IPsec, it is now, increasing security at little to no loss in data throughput.
FortiClient can send security posture tags to FortiOS directly for VPN connections
FortiClient 7.4.5 and FortiClient EMS 7.4.4 introduced the concept of the ZTNA Token, which improves ZTNA tag resiliency because the tags could be sent to a FortiGate in JSON Web Token (JWT) format, and if EMS is not available, tags could still function. The feature can now be used for VPN connections.
Applications can now be marked as sanctioned and unsanctioned. This status can be used in Application and Filter Overrides to easily allow only specific applications, especially since you can change the default application classification to unsanctioned. That way, you have to manually sanction applications.
No longer do we have to rely on Fortinet-provided themes for the GUI. With a full palette of colors and our own hex code combinations, we can display our FortiGate in our colors and give every administrator the choice to pick their own theme.
Take this moment to join me in a hot pink FortiOS.
config system theme
edit "HOT-PINK"
set border-radius enable
set header-color "#ff46a2"
set selected-color "#ff46a2"
set call-to-action-color "#ff46a2"
set accent-color "#ff46a2"
set banner-msg ''
next
end
Jokes aside, this feature was implemented because customers wanted specific colors to classify their firewalls for security and practical reasons. Edge firewalls get a theme, internal firewalls another, testing environments this one, production environments that one, etc.
Wrapping up
[Update 2026-04-27]: Since writing this post, Fortinet has released its own overview video, which covers only a few of the features mentioned in this post. Maybe give it a watch: What’s New in FortiOS 8.0 | Product Overview
Historically, the X.0.0 release gave the direction of where FortiOS is headed and further releases, especially X.0.1, brought lots more interesting features to solidify the direction. At this moment, we can only imagine what such a release holds for us, and I have my own wish list for this, and I’m sure you, dear reader, have your own.
With that, look at the other new features of FortiOS 8.0, and maybe you will find one that you think is just the best.
Zero Touch Provisioning (ZTP) is a wonderful thing that can turn hours into minutes, reduce errors, and help with standardization, among other things. In this post, dear reader, I want to go through the entire process, starting with a factory-reset FortiGate, to one that is fully integrated into an Auto-Discover VPN (ADVPN) environment with SD-WAN. We’ll see FortiZTP, FortiManager, Jinja, and so much more along the way. Read on, and I hope you find this entire process just as cool as me.
Acknowledgement
Before I get into the topic proper, there is one person I need to thank. Dennis Holzmann has given me some valuable information and tips in the past on this topic, and some of them are reflected in this post, as well as my work. This post would look different, and worse, without his input, so thank you, Dennis!
The setup
For this post, I have the following components:
A FortiGate VM running 7.6.6, serving as a hub
A FortiGate 70G running at 7.6.6, serving as a branch
FortiManager running 7.6.6
FortiZTP, a free cloud service
The GitHub repository
The purpose of this post isn’t the actual configuration that is being done, but the process. I will highlight parts of the configuration where I think it’s useful, but for the most part, the configuration of the deployment is up to you. If you are still interested in the configuration I am using, you can check out my Fortinet resources GitHub repository. The finished FortiGate configurations, exports of FortiManager templates, the ZTP pre-run script, and more are all there.
A high-level look at ZTP
Breaking down ZTP provisioning of FortiGates in the Fortinet ecosystem in its most basic steps is as follows:
A factory default branch FortiGate gets an IP, and thus a WAN connection, via DHCP
The branch FortiGate contacts FortiZTP for partial provisioning
A VPN tunnel, as well as a BGP neighborship, gets established between the branch and the hub
The branch FortiGate connects to FortiManager via the VPN tunnel
FortiManager fully provisions the FortiGate
The process itself is simple, but there are a decent amount of pieces we first have to prepare so our ZTP workflow runs smoothly.
Let’s first look at the general FortiManager pieces.
Preparing FortiManager for ZTP
On the hub, we need to prepare four things before we dive into templates:
Device groups
Metadata variables
Policy packages
Blueprints
Let’s take it from the top.
Device groups
To make sure that our FortiGates get the correct configuration, we will target groups as much as possible. How many groups you want to create depends on your needs, but I usually see the need for four:
Hubs
Branches
Branches with single WAN
Branches with dual WAN
I think the groups are self-explanatory.
A FortiGate might start in a single WAN group, and if it ever gets a second WAN connection, you can simply move the device to the dual WAN group and all the necessary things, the second IPsec tunnel and second SD-WAN member, for example, get automatically provisioned and installed on the next configuration push.
The branches group is useful if you ever need to run a script against all branches and don’t want to pick the WAN-related groups, and it is also used for the template group.
Metadata variables
A magical aspect of FortiManager are metadata variables. You can create these variables, assign mappings for devices and/or groups, with an optional default value, and use them in a whole host of configurations in FortiManager. You see the possibility of using metadata variables in fields by the looking glass symbol of a dollar sign , or you know that you can use them in CLI templates and scripts when using both CLI and Jinja.
Again, how many and what metadata variables you need is up to your needs. I need the following for this post:
branch_id
cluster
hostname
wan1_port
wan2_port
branch_id is a variable that will be used for the site subnet and loopback IP for the most part. The cluster variable signalizes if this is an HA cluster, because some things will be different for these installations.
hostname, wan1_port, and wan2_port are self-explanatory.
Policy packages
Without policies, not a whole lot will work, so we need policy packages for the hubs and our branches. It is recommended to create one policy package for all branches and try to make it as homogenous as possible, because branches should be treated like cattle, not pets.
The branches only need one policy, which is a policy that uses the SD-WAN zone with the IPsec overlays as members, which will be created later, because otherwise the IPsec tunnel to the hub won’t ever come up. Everything else is up to you.
The hub needs the standard policies for an SD-WAN/ADVPN deployment with BGP.
Branch IPsec SD-WAN zone to the BGP loopback to allow BGP traffic
Branch IPsec SD-WAN zone to the health check loopback to allow ping traffic for our SLA
Branch IPsec SD-WAN zone to Branch IPsec SD-WAN zone for shortcut tunnels
Again, everything else, including policies that allow traffic to LAN resources, is up to you. My policies are visible in the GitHub repository and here:
Blueprints
We don’t want to spend lots of time in a ZTP deployment creating model devices, or waiting for the actual device to come online, and assigning things. We use blueprints to speed up the process.
Pick your device model, enable auto-linking, enforce a firmware version and the device configuration, assign groups, templates, a policy package and configure HA all in one fell swoop and reuse it hundreds of times. In the blueprint, we also define that we use the SD-WAN Manager for all branches, and the hub(s) will be added manually.
The blueprint you create will then be used to create model devices that define how your FortiGate will look once FortiManager has provisioned it.
I got one blueprint for single WAN, one for dual WAN and a third one for HA (more on HA later).
Single WAN blueprint
Dual WAN blueprint
HA blueprint
With all our non-template pieces in place, we can now turn to templating, and we will start with the branches.
Overlay Orchestration
Just joking, one more point. I want to point out that the SD-WAN Manager offers a feature called Overlay Orchestration. This feature creates an SD-WAN Overlay Template, which builds the necessary templates for you, and you only need to supply some values regarding the configuration (topology, loopback IPs, AS number, groups, etc.). This is a quick and easy way to get started, but I wouldn’t recommend using it except to get the initial templates. The reason for that is that if you ever edit the overlay template, it will overwrite all custom settings you made in the templates.
Once the Overlay Orchestration feature gets the capability to only edit its own settings in templates, it will be a great way to handle an entire rollout.
Now onto the branches.
Templating the branches
I configured the following templates for branches:
A system template
CLI templates that are in a CLI template group
A BGP template
An IPsec tunnel template
An SD-WAN template
Let’s look at each in turn. I will not spend much time on the configuration of the templates; see the GitHub repository for that, but only point out noteworthy points.
System template
The system template houses options like DNS and NTP servers, the hostname, SNMP, and logging. These options are, of course, specific to your deployment, but one you should keep in mind is the Log Settings option, where you can tell devices to log to FortiAnalyzer, and if FortiAnalyzer is managed by FortiManager, it’s even easier to do.
CLI templates
CLI templates, and the pre-run CLI templates, is where lots of magic happens, not just because you can use metadata variables in them, but because you can use Jinja to make configurations that have some logic in them, like if-else and for loops and functions, like ipmath. I want to highlight three things here:
1. You can import CLI templates in other templates. At first reading, this doesn’t make much sense, but since you can use Jinja, a CLI template can be used to house variables that, after an import, can be used in other CLI templates.
Consider the classic case of creating VLAN interfaces on branches. It’s not rare for branches to have 5+ VLAN interfaces, maybe one is for management and needs different management access, and some with DHCP relay enabled. You can create a CLI template and write out the configuration as is, but this definitely isn’t cool, and the cool-factor has a certain charm.
Instead, create a CLI template that holds a variable with your VLAN interface information, import it in another CLI template, loop through your interfaces, and make the individual changes as you need them. If you ever need to add another VLAN interface, just copy the line and replace the values.
In my configuration, I have done just that, so take a look:
{# CREATE INTERFACES #}
{%- import 'CLI-000-PROJECT-VARIABLES' as project_variables with context -%}
config system interface
edit "LAG_INTERNAL"
set vdom "root"
set type aggregate
set member "port1" "port2"
next
{%- for vlan_interface in project_variables.interfaces.vlan_interfaces %}
edit "VL_{{vlan_interface.id}}"
set vdom "root"
set type vlan
set vlanid {{vlan_interface.id}}
set interface LAG_INTERNAL
set alias {{vlan_interface.alias}}
set ip 192.168.{{vlan_interface.suboctet}}.1/24
{%- if vlan_interface.id == 100 %}
set allowaccess ping https ssh snmp fgfm
{%- else %}
set allowaccess ping
{%- endif %}
{%- if vlan_interface.dhcp_relay %}
set dhcp-relay-service enable
set dhcp-relay-ip "192.0.2.1"
{%- endif %}
next
{%- endfor %}
end
2. FortiManager comes equipped with some predefined variables you can use in Jinja. You can use these to target only specific models, using {{DVMDB.platform}}, loop through interfaces using {{DEVDB_system_interface}}, and much more. I am using just such a variable in the interface CLI template from point one to check if a second IPsec interface exists to set the management access on the interface.
3. The Pre-Run CLI Template is referenced in the blueprint and runs only once. You can use this for whatever you want, but I like to use it to initially define a password for administrators, because a factory-reset FortiGate has an empty password for the admin user, which is a security risk.
BGP template
There isn’t much to say about BGP templates. It’s a standard configuration for BGP on loopback, which is the recommended deployment. The only thing of note is that I use a route map, referencing a prefix list, to make sure only the local summary subnet can get announced, e.g. branch with ID 101 announces only 10.101.0.0/16. This saves you from some misconfiguration, but how you do this is up to you. You can announce all connected routes if you want, but even then, I’d restrict it to the private IP address space using a prefix list, because otherwise, you might also announce the public IP space that is on the branch’s WAN port.
IPsec tunnel template
IPsec is, again, pretty standard with a BGP on loopback configuration using network IDs. I have two tunnels in the template. One for overlay 1, using the wan1 port, and overlay 2, using wan2, and the tunnels target different device groups. Single and dual WAN for both overlays, and only the dual WAN group gets overlay 2.
One part that might be interesting is that I have enabled mode config on both tunnels despite using BGP on loopback. The reason is that when the hub gives out IP addresses to the branch IPsec interface, we can do two things:
Enable management access on the IPsec interface, and the hub can then connect to the branch via the tunnel, which can be helpful in a pinch. Just make sure the hub is allowed according to trusted hosts and local-in policies.
It saves you from having to define the source IP for all your possible local-out traffic (FortiAnalyzer, DNS, NTP, RADIUS, etc.), because FortiOS, unless defined otherwise, will use the IP of the tunnel interface as the first choice to source this traffic.
Both these zones have both members defined, but the installation target narrows down who gets what.
In the SD-WAN Rules, both members are defined, and we install these rules on the branch group, not the single/dual WAN groups. FortiManager is smart enough to only push the SD-WAN members that actually exist on the device in these rules.
Templating the hub
The hub, already registered and synchronized in FortiManager, performs the following functions:
Terminates dial-up IPsec connections of branch devices
Forms a BGP neighborship with all branch devices
Acts as a BGP route reflector (optional)
If you use dynamic BGP for your spokes, you don’t need the hub to be a route reflector
Serves as the ADVPN hub
The templates for the hub are very standard, so I can keep the noteworthy things short:
CLI templates: As necessary
BGP templates: Very standard, except here I’m using a route map with a prefix list to allow only private IP addresses from the RFC1918 space
IPsec tunnel templates: It’s a dial-up hub with two dial-up tunnels and mode config enabled to hand out IPs, as mentioned in the branch section and it also gets an IP address just outside of that range (the following picture is edited, and the Tunnel Interface Setup section is further down)
SD-WAN: Uses a remote performance SLA with priority IN-SLA and priority OUT-SLA values to dynamically steer branch traffic.
Now that we have all our templates, we can create the template groups and assign them to our groups.
FortiZTP
This is the thing that starts the entire process in earnest. FortiZTP is a free service Fortinet offers to facilitate Zero Touch Provisioning of FortiGates, FortiAPs, FortiSwitches, and FortiExtenders. It is available in the Services section of support.fortinet.com (click on “Show More”) or at https://fortiztp.forticloud.com. As soon as a supported device is an asset of your FortiCloud account, it will show up in FortiZTP ready to be provisioned.
If a FortiGate starts from a factory default/reset state, it will:
Try to pull an IP address using DHCP on ports that have the DHCP client enabled. The ports vary by model, but for the smaller devices, the WAN ports have it enabled.
Try to connect to FortiCloud and look if it is provisioned for ZTP
Connect to a selected target:
FortiGate Cloud
FortiManager Cloud
An on-premise FortiManager
Execute an optional pre-run CLI script
[Update 2026-04-08]: I wrote factory default/reset state, and this is important because if the configuration has been touched, the ZTP process will never be started by the FortiGate. You can check the status using thediagnose fdsm fmg-auto-discovery-status command. If the output shows config-touched=1, no ZTP will happen. Thanks to Chris Eddisford from Fortinet for reminding me about this command.
The pre-run CLI script is the first thing we define. It has all the information the FortiGate needs to connect to FortiManager. At its most basic, and keeping the BGP on loopback configuration in mind, we need six things:
The loopback interface for BGP
An IPsec configuration
A BGP configuration
An SD-WAN configuration for our member and zone
A policy that uses the SD-WAN zone so that the tunnel establishes
The FortiManager configuration
An optional seventh step is the breaking of the default switch interface, as well as the deletion of the fortilink interface. This is included in my pre-run CLI script. Note that the name of the switch might be different for your model.
My somewhat overblown, pre-run CLI script is here:
ZTP pre-run CLI script
config system interface
edit "LO_BGP"
set vdom "root"
set ip 172.25.1.252 255.255.255.255
set allowaccess ping
set type loopback
next
end
config vpn ipsec phase1-interface
edit "HUB1-O1"
set interface "wan1"
set ike-version 2
set dhgrp 32
set proposal aes256gcm-prfsha512
set keylife 28800
set peertype any
set mode-cfg enable
set remote-gw 192.0.2.1
set idle-timeout enable
set idle-timeoutinterval 10
set net-device enable
set add-route disable
set exchange-interface-ip enable
set exchange-ip-addr4 172.25.1.252
set auto-discovery-receiver enable
set psksecret ENC JVojDRO7n0Wkft6+Ts9vwzs0m891XrhCH5/N/fU5xATS+bTAflRfv/tS/vFpjOyZ2+1FZcg+aniAcpQ0uUsUCrBIF6jPfVEfPHSXsZW5KE+DiDoOBWIFp7mgkgdZppgPZWZbaErBlQ0R6koV1N0bCgByN4otCT+nBSU1DorQBa5Z5jifcYIiTtdUk5RulqIjBRcnbFlmMjY3dkVA
set network-overlay enable
set network-id 10
set auto-discovery-shortcuts dependent
set exchange-fgt-device-id enable
set transport auto
set shared-idle-timeout enable
next
end
config vpn ipsec phase2-interface
edit "HUB1-O1"
set phase1name "HUB1-O1"
set proposal aes256gcm
set auto-negotiate enable
set dhgrp 32
set keylifeseconds 3600
next
end
config router prefix-list
edit "PL_RFC1918"
config rule
edit 1
set prefix 192.168.0.0 255.255.0.0
unset ge
set le 32
next
edit 2
set prefix 172.16.0.0 255.240.0.0
unset ge
set le 32
next
edit 3
set prefix 10.0.0.0 255.0.0.0
unset ge
set le 32
next
end
next
end
config router route-map
edit "RM_SDWAN-OUT"
config rule
edit 1
set match-ip-address "PL_RFC1918"
next
end
next
end
config router bgp
set as "65123"
set router-id 172.25.1.252
set keepalive-timer 5
set holdtime-timer 15
set ibgp-multipath enable
set additional-path enable
set scan-time 15
set graceful-restart enable
set recursive-next-hop enable
set tag-resolve-mode merge
set recursive-inherit-priority enable
config neighbor
edit "172.25.1.254"
set advertisement-interval 1
set activate6 disable
set capability-graceful-restart enable
set link-down-failover enable
set soft-reconfiguration enable
set interface "LO_BGP"
set remote-as "65123"
set route-map-out "RM_SDWAN-OUT"
set keep-alive-timer 5
set holdtime-timer 3
set connect-timer 15
set update-source "LO_BGP"
set additional-path both
next
end
end
config system sdwan
set status enable
config zone
edit "SDWAN-HUBS"
next
end
config members
edit 1
set interface "HUB1-O1"
set zone "SDWAN-HUBS"
next
end
end
config system dhcp server
purge
end
config system ntp
set server-mode disable
end
config firewall address
del lan
del internal
end
config firewall policy
purge
end
config system virtual-switch
del lan
del internal
end
config system interface
del fortilink
end
config firewall policy
edit 0
set name "DUMMY VPN"
set srcintf "LO_BGP"
set dstintf "SDWAN-HUBS"
set action deny
set srcaddr "all"
set dstaddr "all"
set schedule "always"
set service "ALL"
set logtraffic all
next
end
config system central-management
set type fortimanager
set serial-number {{fmg-sn}}
set fmg {{fmg-ip}}
end
Note: The interface binding for the pre-run script varies model by model. A 50G only has wan, for example, instead of wan1 and wan2 for the 70G. Keep this in mind when making your scripts and create several scripts that map to your devices.
I have purposefully removed all leading tabs and whitespaces from the script to reduce possible errors when the script runs.
With the pre-run script, we can define our FortiManager and attach the script.
Now we can go to our Assets, select our FortiGate(s), and provision them to target our FortiManager. Here you can also select a different script if the default one attached to your FortiManager isn’t the one you need.
Now our FortiGate is ready to be provisioned by ZTP.
The CSV file
The last piece of, technically optional, preparation we need for ZTP is a CSV file to create our model devices. The CSV file, at a minimum, needs the serial number, the name of a blueprint, and the name the device will have in FortiManager.
You can, and probably will, add several metadata variables in addition to that to the CSV. With the CSV file in hand, you can use it to create your model devices, look over the assigned values, and continue with your import.
If you ever need to give the creation of the CSV off to someone else who might not be as technically inclined as you, consider creating an Excel sheet to easily fill out and select values. After that, it can be exported to a CSV.
An example CSV file is provided in the GitHub repository.
Auto-linking
The process of connecting an incoming FortiGate, using its serial number, to a model device is called auto-linking. After a successful link, the real FortiGate will get handled according to the blueprint, meaning the firmware might get upgraded, it gets added to groups, a policy package gets pushed, etc.
You can see the status of auto-link in the corresponding column. It will go from Enabled to Auto-linking and disappear once it is done.
Auto-linking creates two tasks on FortiManager:
Push config to device: This signalizes the pushing of the entire configuration to the FortiGate
Install Package ‘<PACKAGE-NAME’>: Installs the policy package
If either of these tasks fails, look at FortiManager’s Task Monitor and the installation reports in the tasks to find out where your errors are. In the following picture, I forgot to first install the device configuration to the model device, so the SDWAN-OUTSIDE zone was missing, and that is needed in the policy package.
Now we finally got everything, so let’s look at the process from start to finish.
The end-to-end ZTP process
Let’s now go through the entire process of how to ZTP a FortiGate.
Provision the FortiGate on FortiZTP
Create a model device on FortiManager using the CSV
Do a Quick Install on the model device
This is necessary if you are enforcing the device configuration in the blueprint, because the installed configuration is needed for everything else to correctly install, like firewall policies
Connect your FortiGate, which is factory reset or booting for the first time, on ports that have DHCP client enabled, to a modem/router for internet access
The FortiGate will try to contact FortiZTP to check for any provisioning
The pre-run CLI script will be downloaded and run
The FortiGate will connect to FortiManager
FortiManager will auto-link the FortiGate to the model device
If enforced in the blueprint, the firmware of the FortiGate will be upgraded
If the firmware was upgraded, signatures and databases like the ISDB, IPS, and application control might get upgraded as well
The full configuration, including the policy package, gets pushed to the FortiGate
On FortiManager, the config status becomes Synchronized, the policy package status gets a green check mark, and on FortiZTP, we see Provisioned
ZTP is done
I have made a short video about the entire process if you want to see it in action:
Note: In some cases, I’ve seen an “Out of Sync” message shortly after the ZTP process is done, which can be related to things like FortiTokens that get provisioned after the device comes online. These things should get corrected with the next install.
The process is now explained, and hopefully clear, but we’re not done.
ZTP with FortiGate clusters/HA
Most ZTP installations I’ve seen are single devices, but there is a need to create clusters during ZTP as well. This has some caveats, but it’s nothing insurmountable.
It all starts with the blueprint, where, after enabling the HA option, you can fill out information relating to the monitor and heartbeat interfaces and assign a password for the cluster.
Next, you need a CSV that handles the cluster configuration. This CSV has some more necessary values in addition to the serial number, blueprint, and the name. The new values are:
cluster id: Defines the group ID of the cluster, which is important if you ever have two FortiGate clusters in the same layer 2 network
cluster name: Self-explanatory
Priority: Sets the cluster priority of each FortiGate in the cluster, and needs to be different between the two units
Mode: Either AP for Active-Passive or AA for Active-Active
Note: The name value needs to be different for each device in the CSV; otherwise, the import will not work correctly.
An example CSV for a cluster is available in the GitHub repository.
Once the model HA device cluster has been imported, the process is the same as for a single device; you just need to make sure that your FortiGates are correctly cabled up.
If you ever import multiple clusters, the cluster ID is responsible for differentiating between them, and this is color-coded in the import.
Important point: I have not found a way to supply FortiManager metadata variables using the CSV for an HA model device, so this is something you have to do manually after the import. This is easily done by right-clicking on the model device and selecting “Edit Variable Mapping”. If someone knows how to do this, please tell me.
The hostnames of the HA devices will look a bit strange after the import, and in my configuration, the hostname also won’t get changed by a CLI or System template, because HA hostnames are individual to each cluster member.
When it comes to FortiZTP, it is important to have a second pre-run CLI script for the second member in a cluster, because of the overlapping IPs and router IDs, which create a problem during the ZTP process on FortiManager. So you need to adjust the following in the second CLI script:
The IP on the loopback interface
The BGP router ID
The exchange IP address in the IPsec phase 1 configuration
In FortiZTP, you then provision the first member with the regular pre-run script and the second member with the second script.
Scaling beyond 253 branches
This is a niche case, but if you ever have more than 253 branches and only one hub, the regular way to use the branch_id metadata variable for your loopback IP and other things breaks down. If your loopback IP with the variable looks like 10.10.0.$(branch_id) and branch_idis 300, this obviously cannot work.
If this case applies to you, you will have to use Jinja CLI templates to set these things instead of regular CLI templates and set these values in IPsec, BGP and SD-WAN templates. FortiManager luckily comes with the Jinja ipmath function that helps us here.
By using {{'10.10.0.0'|ipmath(branch_id|int)}}, you can scale to much more than 253 branches, for example. With the above branch_id of 300, this would lead to the IP 10.10.1.44.
How you handle the addressing for your networks, and thus the BGP announcements, in such a case is up to you. At that point, you might have to create a metadata variable that defines the local network as a summary and map it to each device. In my CLI template, I have created an if condition that only creates a network entry in BGP when the branch ID is lower than 256, because everything else would result in an error, and it also uses a route map to filter to RFC1918 space instead of only the local network summary.
In the GitHub repository, you can find a CLI template called “CLI-004-IPMATH” that has all the settings where you, at a minimum, need to use the ipmath function if this case applies to you.
Additional resources
I am, of course, not the first person to create content about this topic, and this post isn’t about the configuration itself, but the process. If you would like some more information on how configurations can work, please check out these two resources by Fortinet themselves:
4-D SD-WAN resources: Includes documents detailing the topologies, deployment guides, explanations, and GitHub repositories with working example configurations. Thanks to Cameron Keighley and kli who are the GitHub contributors!
The Jinja Orchestrator: A repository containing best practice SD-WAN/ADVPN configurations and templates, you can deploy with or without FortiManager. It’s an extensive and fantastic resource. Thanks to Dmitry Perets and Boris Henicker, who are the GitHub contributors!
Enough ZTP
I think I have touched on everything I wanted to cover regarding the current state of Zero Touch Provisioning with FortiGates and FortiManager. It might seem like it’s a lot, but the process is quite simple once you’ve seen it.
With all this information, I wish you, dear reader, happy zero touch provisioning.
As I was preparing for Accelerate 2026 and the Ultimate Fabric Challenge, one of the topics I wanted to focus on a lot more was Security Operations (SecOps), because this is an area I was always weak in.
SecOps, in this context, means working with FortiAnalyzer automation, i.e. event handlers, playbooks, incidents and connectors. A part I really wanted to immerse myself in was how FortiAnalyzer handles variables, and in the process, I think I gained some useful knowledge that I now want to show the world. So, dear reader, please, read on, and maybe this proves useful to you. It was relatively quick for me to complete, but it’s not going to be short, though there are lots of pictures!
The setup
For this post, I have the following components:
FortiAnalyzer running 7.6.6
FortiGate running 7.6.6
FortiAuthenticator running 8.0.1
An Ubuntu server to receive some POST requests
A primer on playbooks
FortiAnalyzer uses playbooks to automate various workflows using connectors that integrate other systems, like FortiGates, FortiClient EMS, FortiAuthenticator, etc. Every playbook uses a trigger, which is usually an event or an incident, but you could also run it on demand or on a schedule. After this trigger various tasks are executed that define what connector is used, various parameters, and variables, to accomplish, well, lots of things.
Playbooks can be simple, only sending a mail with information from an event, and fully orchestrate an incident response flow, by triggering on an event, creating an incident, running a report, banning IPs, sending mails, and you could go on and on, really. For this post, we’ll keep it simple and focus on how to work with variables.
Setting the stage
For the first type of variables, we’ll look at connectors and, in this case, specifically, the one for FortiAuthenticator (FAC). The FAC connector is one that works with HTTP-based communication, so whatever FortiAnalyzer sends to FAC can be easily read in cleartext.
The FAC connector has a few actions, and for our example, we’ll look at two:
Get User List, which fetches a type of user (LDAP, RADIUS, or local) and saves it in a list
Update User Status, which we will use to deactivate a user using an ID
I have prepared the following playbook:
There are four tasks in this:
An On Demand trigger
The Get User List task, using the FAC connector, to get the user list
The Update User Status task, using the FAC connector, to deactivate a user by ID
A generic webhook task that sends an HTTP POST request
The generic webhook is what is used to demonstrate one way we can check what FortiAnalyzer sends.
The configuration of the webhook looks like this:
The protocol being HTTP is important.
The target of the webhook is the Ubuntu server that uses http-echo-server to simply echo any incoming POST requests to the terminal.
[Update 2026-03-26]: Chris Eddisford from Fortinet has shown me webhook.site, which you can use as a generic webhook receiver, so you can send your POST requests to your unique URL and immediately see the result. No sign-up or Ubuntu server needed. Thanks a lot, Chris!
Here is the configuration of all tasks, except the On Demand trigger, because there isn’t anything to show for that one:
FortiAnalyzer FortiAuthenticator get users task
FortiAnalyzer FortiAuthenticator disable user task
FortiAnalyzer send POST task
If I run this playbook as is, it won’t actually do anything on FAC, because the Update User Status task expects a specific user ID, and not a list of users, but we’ll get to that. At this point, we care about what FortiAnalyzer sends.
Note: I want to stress that this playbook exists purely as an exercise to show the process. In reality, you wouldn’t actually do this. In a production environment, you’d get the username from a triggered event, pass the username to the Get User action to retrieve the user ID and then use the Update User Status action with the user ID as your input.
Getting the variable values from HTTP-based connectors
If I run this playbook, the playbook will run successfully, and my Ubuntu server will get a POST request.
In the POST request we see that FortiAnalyzer uses a Python library to send the request (as a matter of fact, playbooks in general use Python, which you see clearly if you have an error), and we get the JSON payload consisting of two important dictionaries: meta, which has, as the name says, meta information, and objects, which holds a list of dictionaries that have the actual entries that were retrieved from FAC.
You can paste the entire payload into your favourite JSON formatter, like JSON formatter, to make it more readable.
But let’s assume you don’t have a Linux server with this http-echo-server available, and you want to get the information using only FortiAnalyzer. Don’t worry, this requires only marginally more work.
Important point: Before FortiAnalyzer sends a POST, it checks if it can connect to the destination over the specified port (it doesn’t matter if the port can do something with the payload), so if you don’t have a target with an open port, this won’t work. Luckily, FortiAnalyzer is definitely available, so you can use that as your target in the webhook connector as well as the task and send an HTTP POST to FortiAnalyzer.
First, start a sniffer on FortiAnalyzer that captures Ethernet data. Using my generic connector, my sniffer looks like this:
diagnose sniffer packet any ‘host 192.168.1.151 and port 8081’ 3 0 a
Once that is set up, run the playbook again and look at the output of the sniffer to find the payload, which starts with the meta dictionary.
Copy the payload from meta to the curly brackets at the end to your text editor of choice (mine is Notepad++) and perform the following find and replace actions:
Replace \r\n (line breaks) with no character using the Extended function
Replace :. (colon dot) with . (dot)
Replace :, (comma dot) with , (comma)
After that, you should have the entire payload on a single line, and you can, again, paste it into a JSON formatter.
Here is a short video where I show this process if you want to see it in action:
With the JSON information available, we can easily find out what information has been retrieved, how it’s structured, and how it can be further used.
Using my payload, I can get the ID for the user “adkevin@ad.labdomain.com” by accessing objects[1][‘id’].
With this information, I can now change the Update User Status task accordingly.
Putting the pieces together
When you edit a task that includes variables, you have two methods to set variables:
Select them from a selector menu
Writing the variable string yourself
You can combine these methods by first selecting a variable, clicking on the “A” and then editing the variable as you need it.
The FAC Update User Status task wants the ID that FAC itself assigned, so if we first select the user_list variable from the previous task, then switch to the manual input, we can now add the information we got previously. This can look like the following:
If we now run the playbook again, the user with the ID we got will now be correctly disabled on FAC, as signalled by the red X in Status.
This was for HTTP-based connectors, but I also want to show how you can get variable information to use in FortiGate automation stitches.
FortiGate automation stitches and FortiAnalyzer
If a FortiGate logs to FortiAnalyzer and you have configured an automation stitch that uses an Incoming Webhook Call as a trigger, the FortiGate will show up as a connected device in the “FortiOS Connector”
For this post, I have configured a basic automation stitch that, as an action, creates an address object and adds it to a group. I have this group as the source in a deny policy, and the idea is that if this stitch is triggered, the offending IP will get automatically blocked.
The trigger, action, and stitch configuration is as follows:
FortiGate incoming webhook
FortiGate automation action to add an address object and add it to a group
FortiGate automation stitch
But we’re getting ahead of ourselves here, because in the action picture, I’m using variables, but how do I even know what they are called and what they represent?
Setting the stage for automation stitches
A FortiGate gets the information from FortiAnalyzer in the log variable, which can be accessed using %%log%% in an action. This information is directly available in the GUI by clicking on the % symbol.
This is already a big help, but it doesn’t help with showing you the entire list of variables that FortiAnalyzer makes available to you, so let’s find out how to get this information.
First, we need to change the action on the FortiGate. By using the %%log%% variable, as shown in the picture above, we can assign this variable a value on FortiAnalyzer with a playbook.
Second, we need a playbook, and in this case, I have a simple one with two tasks:
Trigger on an event
Execute a FortiOS webhook
The trigger matches on the event SSH-GOOGLE, and the event handler simply checks for SSH connections to the Google DNS server.
FortiAnalyzer event trigger for FortiGate
FortiAnalyzer SSH-GOOGLE event handler
The FortiOS webhook task has the information about the FortiGate device where this action gets executed, the webhook name, and the variables, which are taken from the automation action.
An important point here is that the variables you use in an action will be dynamically and periodically synced to FortiAnalyzer, meaning that if you change or add variables in an action and wait a bit, FortiAnalyzer will show the updated variables in the task. You see this in two places:
The task itself
By clicking on the “x device(s) connected” message in the FortiOS Connector (see the picture above)
Here is a comparison of how it looks if we use the variables from the action we will use to create and add an object to a group, and the action we use to get the information for the full information.
The last piece we need, before seeing the values for the variables, is telling FortiAnalyzer what variable to send, and this is done in the FortiOS webhook task.
In this task, we have, much like with the FAC example from earlier, two ways to accomplish this:
Select the variable from a selector menu
Writing the variable string yourself
By selecting the variable, you can go through the entire list of available ones, but if you’re not quite sure what you’re looking for, this is tedious, because you’d need as many variables in the FortiGate’s action as there are variables.
The better method is simply sending everything from the trigger, which is accessed via the variable ${trigger}.
With this trigger variable set for the FortiGate’s log variable, we can finally get to finding our values, and this is quite simple.
Getting automation stitch variable values
On the FortiGate, we are going to set up a debug for the automation stitch application, and there we’ll see what is being delivered by FortiAnalyzer. The necessary commands are as follows:
With this prepared, we can trigger the event handler by trying to connect to Google’s 8.8.8.8 DNS via SSH, and after a bit, the debug messages will come in.
And here we got our variable names and their associated values, right for the picking and inserting into our automation action. Now we can change our script in the FortiGate action to create an object using epip in the name and subnet, putting mkey and date in the comment, and adding the object to our group (see above or the debug below for the full script).
Trigger the event handler again, and everything looks as it should.
FortiGate automation stitch real debug
__action_cli_script_open()-171: cli script action:CREATE-BAD-HOST is called. svc ctx:0x55a1ff3be410
accprof:super_admin script:
config firewall address
edit "H_%%log.epip%%"
set subnet %%log.epip%% 255.255.255.255
set comment "%%log.mkey%%_%%log.date%%"
next
end
config firewall addrgrp
edit G_BAD-GROUP
append member "H_%%log.epip%%"
next
end
__read_cli_script_result()-117: cli script:
autod.2
output:
========== #1, 2026-03-23 19:25:33 ==========
FGT02 config firewall address
FGT02 (address) edit "H_192.168.1.231"
FGT02 (H_192.168.1.231) set subnet 192.168.1.231 255.255.255.255
FGT02 (H_192.168.1.231) set comment "Incoming Webhook Call_2026-03-23"
FGT02 (H_192.168.1.231) next
FGT02 (address) end
FGT02 config firewall addrgrp
FGT02 (addrgrp) edit G_BAD-GROUP
FGT02 (G_BAD-GROUP) append member "H_192.168.1.231"
FGT02 (G_BAD-GROUP) next
FGT02 (addrgrp) end
======= end of #1, 2026-03-23 19:25:34 ======
Wrapping up
I have uploaded the various Fortinet resources from this post (playbooks, event handler and FortiGate configuration) to a GitHub repository, if you want to look at everything yourself. In the future, I will put all blog-related resources in this repository.
I did basically everything here before Accelerate 2026, and it helped me become more confident in SecOps tasks, which was why I put this topic on my to-do list in the first place.
There is definitely more that can be shown regarding this topic, and a better version of this post exists somewhere in a parallel universe, but perfection is something you can strive for; reaching it should never be the goal. My main goal was to cover blind spots that are not mentioned anywhere. I hope this covering of blind posts helps you, dear reader.
This is a blog focused on technology, but, dear reader, if you would indulge me this time and read about what happened to me personally during Accelerate 2026, and the accompanying Ultimate Fabric Challenge, I would be grateful, because this topic is near and dear to my heart.
What are these things?
Accelerate is Fortinet’s yearly convention where the focus and strategy of Fortinet is made public, big changes are announced, and the newest things, like hardware, products or features, are presented. For me personally, the focus has always been about networking and the Tech Expo, where booths are staffed by Fortinet product experts you can ask questions, or in my case, bother them about things (and I am always sorry to be such a bother). I write „has always been“ with purpose, because this time the biggest focus was the Ultimate Fabric Challenge (UFC).
The Fortinet Ultimate Fabric Challenge
The UFC started as an internal competition among Fortinet employees, where you configure Fortinet products to accomplish tasks. These tasks can range from creating firewall policies, security profiles, automation flows, and so much more. It went from humble beginnings, with tasks on paper, and using FortiGate address objects to confirm said tasks, to a full-blown game, written in Godot, where you walk around a virtual environment, collect hints, and configure a wide range of products. At some point, the UFC was made available as a competition to external people and at this point, it’s a huge part of the community.
Every major region (USA, EMEA, APAC, LATAM, and Canada) has its own UFC at the associated Xperts Summits, which serve as qualifiers, and the best of each region compete against each other in a 45-person free-for-all at the global final at Accelerate every year. At the finals, it’s not just about bragging rights, but also about a cash prize. $10000 for 1st place, $5000 for second, and $2500 for third place.
Accelerate 2026 was my first global UFC, but before I tell you about that, I have to turn back time to 2025.
In 2025 I attended Accelerate 2025 in Berlin and as I was watching the global final there with Christina Bäck, head of channel management at Fortinet Austria, she said to me „Kevin, you have to participate. You always place well in workshop competitions and you won the Austrian Fabric Challenge last year. Sign up for the Xperts EMEA UFC and you will surely qualify.“.
Before that sentence, I hadn’t really thought about competing in the UFC, but I took the encouragement to heart and signed up for the qualifier that would be held a few months later in Vienna, so I had the hometown advantage at least.
Long story short, I placed 3rd, and since the top 10 go to the global final, my next goal was clear. I want to upgrade from bronze.
Xperts EMEA 2025 3rd place
Oh, and I told my friend, Florian Fuchs from Arrow, that if I get into the money at the global final, we’re going to go to a cowboy outfit store and get cowboy hats to wear at Accelerate, so that was also a nice incentive.
Preparing for the global Ultimate Fabric Challenge final
My idea was to start a four-week preparation phase before Accelerate, where I would go over new features of recent beta releases and work on things I know I’m weak on. A bit before my official start, the other Austrian who qualified for the global final, Dominik Vollman from d-con.net, and I did two older UFCs at the Fortinet office in Vienna as preparation, which helped us to get into the mindset of what to look out for.
Pretty much right as I started with my preparation, all global final participants got an email from David Chan, the person who is responsible for everything UFC-related at Fortinet, with a survey on our experience with certain Fortinet products and topics. I copied the list and immediately sent an email to our channel manager at Fortinet in which I asked for trial licenses for all products in the list, plus a few more. While waiting on the licenses, I formulated a plan of what I am going to study, now that I have a list, and how to do it. I downloaded relevant release notes, assembled lab and new features guides, and with the licenses in hand, I installed the following virtual machines on my Proxmox server:
2x FortiGates (8.0.0 and 7.6.6)
FortiManager (8.0.0)
FortiAnalyzer (8.0.0)
FortiClient EMS (7.4.5)
FortiAuthenticator (8.0.1)
FortiPAM (1.8.1)
FortiNAC (7.6.5)
I already had some virtual Windows and Linux machines, and I also got my FortiSwitch 424E and a physical Windows client out for some 802.1X/MAB/NAC topics.
With all that set up, I went through as much as possible in the following four weeks.
There isn’t much to say when it comes to studying itself. The 8.0 release notes and new features were easy to go through. I assembled as many configuration examples as possible, cemented my dislike for FortiNAC, and finally demystified FortiAnalyzer playbook variables for myself (this was really necessary, because Security Operations tasks are historically the hardest for me in Fabric Challenges, and playbook variables play a big part here).
Each week had anywhere from 12 to 16 hours of UFC study time, and I also studied for the FortiSASE Enterprise Administrator exam on the side, because during Accelerate, you can take one free exam, and I needed to refresh my FortiSASE exam status.
The last day before leaving for Las Vegas was spent setting up my lab to be accessible remotely, because I knew I would spend the day of the UFC preparing as much as possible. My remote lab consists of:
A FortiGate 70G that serves as a VPN gateway and switch controller, connected to the regular LAN and an out-of-band (OOB) network
The aforementioned Proxmox server, which runs all the virtual machines and is connected to the regular LAN and the OOB network
A FortiSwitch 424E connected to the FortiGate
A physical Windows client that connects to the FortiSwitch and the regular LAN
Do you want to see what that looks like? Are you ready? No, you’re not.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
FortiSwitch and client
Proxmox server and FortiGate
I am truly a world-class professional.
Saturday morning, before leaving for the airport, I noticed that David Chan sent out our UFC onboarding mail with some information to help us prepare, but there wasn’t anything unexpected in the provided information as far as topics are concerned. I still downloaded a few more things just in case.
On the flight itself, I read through various administration and lab guides, watched some entertainment media, and slept for about three hours total on my 18-hour journey.
Leisure time in Las Vegas
I arrived at the Mandalay Bay hotel on Saturday evening, and after getting myself situated, I met up with some people from the Fortinet community (all but one being UFC competitors) to go out to dinner at Best Friend at MGM Park’s Eataly. We got ourselves some Banchan samples, fries and the Big Boy Platter. I have no problem recommending the location if you have a craving for some good food.
The Big Boy Platter
Sunday started with a nice breakfast of Salmon Benedict at Seabreeze Cafe, which I enjoyed greatly.
After that, I went to Maverick Helicopter for a Grand Canyon tour. The day before, I was luckily offered an upgrade to a more expensive package, which I gladly accepted, and Cirque du Soleil’s Ka show in the evening. The Grand Canyon tour is an easy recommendation. Getting out of the helicopter and feeling tiny next to these majestic rocks is quite an experience. Ka, on the other hand, wasn’t that exciting. Go see O instead if you care about what I think.
Bottom of the Grand Canyon
Cirque du Soleil’s Ka
In between the tour and the show, I also did my Accelerate registration, got the recording for my UFC battle card done and picked up my UFC competitor shirt.
Last-minute prep
My plan for Monday, competition day, was clear:
Wake up at 6 AM and start studying
Go for breakfast at 07:30, and incidentally meet the Australian NSE8 legends
Study until 12:45
Go for lunch and pick up my NSE8 welcome gift
Study until 14:15
Walk around the venue and do some shopping in the FortiStore until 16:00
At 16:15, I was at the UFC stage and was greeted by a nice setup.
The UFC arena
More of the UFC arena
My UFC spot
So everyone knows what I represent
At around 16:45, the briefing for the UFC started, where we got some information on the new game client release we are going to use (all the features are greatly appreciated, by the way!) and some last-minute information on what to expect in the competition itself.
At around 17:03, we started.
The Accelerate 2026 Ultimate Fabric Challenge global final
The best of the best compete for three hours to gain glory, the gold medal, and the $10000 prize, and I wanted it all.
The competition was split into three parts. FortiOS 8.0 features, FortiDLP, and OT, which turned out to be mainly Security Operations. I had already decided to tackle the sections as I just listed them, so I went to configure 8.0 features.
It all started pretty good. I knew what to do and executed, but I hit a snag on the last configuration point of the first subsection, which was simply making sure that traffic gets blocked by a web filter, and I couldn’t figure out what the problem was, because the actual validation was correct. After spending too much time on it and not being able to progress, I went to FortiDLP, but quickly ran into an issue there as well with a rather simple task. After troubleshooting and getting staff to look over the issue it came to light that what I did on the FortiOS part impacted the FortiDLP part, and once I got the FortiOS web filter part correct (only due to the help of the other Austrian in the competition) I was able to progress on both sections, but not before falling all the way down from 2nd to 32nd place.
Since I was already on FortiDLP, I continued there and, with a single exception, had no issues with anything. I don’t have any practical experience with FortiDLP, but I did a lab at Xperts EMEA 2025 and still had the lab guide, so I had a bit of an advantage over several other competitors in this regard. The FortiDLP administration guides were also part of my in-flight reading material, so nothing was completely new to me here.
With the FortiDLP section done, I went back to FortiOS 8.0 features, and on every task, I immediately knew what to do, so nothing kept me occupied for too long, because I had already gone through everything as part of my preparation. If I needed a refresher, I just opened a prepared list of documentation links and got back to configuring.
FortiOS 8.0 and FortiDLP were done, so only OT remained, and I was now in around 11th place. I was not looking forward to OT, because I didn’t quite know what to expect. It turned out OT was mainly working with network segmentation, FortiAnalyzer event handlers, and security profiles. This I can do, and I can do it well, since I’ve held FortiAnalyzer workshops before and have gotten so much training on exactly these things.
OT also had the single best task I’ve ever seen at a fabric challenge. I can’t go too deep into the specifics of the task, but it required you to configure something that could normally take over an hour and lots of analysis to accomplish. There was a hint in the environment that told you to use generative AI along with a prompt, and after asking ChatGPT, it gave me the result. This all took about two minutes. Quite the improvement from possibly more than an hour of work. This is how AI shows its value. By assisting, not replacing us.
I went through the OT section with confidence and speed, and I moved up and up the leaderboard until I was in 2nd place, where I got to a task that wouldn’t complete. I checked and rechecked, and re-rechecked, but couldn’t find the error, so I waved some staff member over, and it turned out the backend check itself had an error. While the check got fixed, I completed some other task, because if you get stuck on something, you can’t hammer away at it forever. You need to use your time efficiently and get points somewhere else.
With the task fixed, I quickly moved on to the last OT subsection, and this is where it all has to end one way or another.
There were only 10 minutes left, and I was still in 2nd place. If I get a medal, I don’t want it to be silver. I either win it all or barely get onto the podium. I don’t want to barely not get 1st place.
The last section had four tasks. I quickly got the first two done and read through the description of the third task. It seemed to take too much time considering how little of it I had by this point, so I read through the fourth task and knew this was doable in the remaining time. I configured and waited for the check to complete.
The time between „finishing“ your configuration and seeing if it’s correct can be agonizing and everything was riding on it now. The check turned green. In the corner of the screen, I saw „Rank 1“. Now I either finish the third task to solidify my number 1 rank or hope that 2nd place doesn’t get any more points.
It was 20:00. That’s when the competition should have ended, but we had a late start, so there were still a few more minutes, but something weird happened. As I was trying to finish the configuration for the last task, the environment froze. My keystrokes didn’t register; I lost the connection to my FortiGate. The competition environment must have been on a timer, and going over the three-hour mark shouldn’t have happened, but it did. After about a minute, everything turned back to normal, but my frustration was visible to everyone watching.
I configured my last application control profile and, assuming it is correct, I only needed to attach it to four policies, and I would have completed all tasks, but I ran out of time. I heard the countdown, and as it came to 3, I realised that I had won. The „Hands off!“ from the side signalized my number 1 spot, my gold medal, my $10000, my preparation being rewarded.
The final scoreboard
After becoming the best
Euphoria is what hit me. I needed a moment to internalize what just happened, but there were people around who were rooting for me. Colleagues, friends, competitors who are also friends, and they all got a hug. It didn’t matter if I knew the person at that time. If you were close to me, you got hugged. I cannot think of a moment in my life that felt better.
Once I was done going around and celebrating with everyone, I needed to take care of myself. I haven’t had anything to eat since lunch, and I purposefully drank very little throughout the day. I asked if there was food around, which there was, and if I could get something to drink, and I did. I sat down and had a nice winner dinner on the UFC stage.
A well-deserved dinner
I talked with the other Austrians at the event until the opening party was over, then we went down to a bar to celebrate some more until 01:00, closing time, and we retreated to our rooms. Sleep was something I didn’t get much of that day. I slept for 1 hour and 22 minutes that night, presumably because of the excitement of it all, but a new day began, and it was Accelerate 2026 proper now.
Accelerate 2026 itself
Needless to say, there were quite a few people who came up to me throughout the week to congratulate me, and every time it happened, it felt like I was on the stage again. Accelerate isn’t just the UFC, however. It’s about where Fortinet goes, new products and solutions, and networking.
Tuesday began with the keynote, which reinforced what we already knew:
AI isn’t going away, with a focus on securing AI usage and using AI for security
OT security continues to be highly relevant
Security Operations also continues to be highly relevant, as shown by Fortinet’s new offering FortiSOC, which provides a single pane of glass for FortiSOAR, FortiSIEM and FortiAnalyzer
These three topics will accompany us for quite some time going forward.
A topic on the side that I thought was incredibly interesting is how the Model Context Protocol (MCP) will be integrated into various Fortinet products in the future, where Fortinet seems to be the first to market
After the keynote, it was a mixture of the Tech Expo, lunch and networking until it was time for the NSE8 event, which was at Speed Vegas, and the activity was driving supercars. I didn’t drive personally, because in my sleep-deprived state it would have been a bad choice, but even driving shotgun in a Lamborghini Huracan is worth experiencing. I sadly had to leave the event shortly after dinner, because I was double-booked and needed to head back to the strip to attend an Exclusive Networks event at Chéri Rooftop. There, I found a fitness soulmate who sees working out and dieting much like me, which was scary, because I am quite idiosyncratic when it comes to these things, but I can’t complain.
Tuesday ended at around 01:00 again, but this time I got a normal amount of sleep.
After breakfast, Wednesday had the global channel session, which is mainly about partners, so it was relevant to me, but what was more relevant was what I talked about with a friend. Remember that cowboy hat thing from further up? Well, on Wednesday, Florian and I went to Boot Barn and got cowboy hats and a shirt to wear at Accelerate. I actually dragged another good friend, Daniel Lamster from Base-IT, along, who is a self-proclaimed cowboy fanatic, meaning he was quite stoked, so we were three people who arrived back at the convention centre with some cowboy attire.
The Austrian Accelerate cowboys
The rest of Wednesday was much the same as Tuesday until the evening, which was the night of the Austrians. It started with a Hummer limousine trip to Smith & Wollensky for a nice dinner, and then a showing of The Wizard of Oz at The Sphere.
How Austrians travel
A nice dinner at Smith & Wollensky
Inside The Sphere
The night ended with some gambling at Caesar’s Palace (I only lost), and a midnight chicken wrap with a return to my room at 00:45. Progress.
Thursday was when I wanted to do my FortiSASE Enterprise Administrator exam, so I walked around the convention centre for an hour doing some final preparation, quickly completed the exam and went back to the usual program of annoying Fortinet employees at the Tech Expo, attending technical sessions, and networking.
Thursday was special for a specific reason, however, because it had the closing party, and the closing party also had the UFC award ceremony.
The final night
At 18:30, the closing party opened, and I immediately went to get something to eat, because I had been waiting the entire day for dinner, and I wanted to eat one of everything. Since the ceremony started at 19:00, I wanted to eat half the things beforehand, do the ceremony, and then go for the other half later. Bad choice as it turned out.
Shortly before 19:00, I ran over to the stage and got a little briefing about the ceremony and helped David Chan with pronouncing my and my employer’s name, because he wasn’t confident in his research. Respect to David for actually researching how to say my name on his own. Haven’t had anyone do that yet.
As the ceremony started in earnest, my adrenaline shot up again until it was my turn to get the medal, and my cheque with $10000 on it. It was amazing to have both things in my hands after all the work I put in. After some obligatory pictures, I had to find out how to get this massive cheque back to Austria, and after a lot of talking and pleading, Filippo Cassini said he’ll take care of it. Huge thanks to Filippo for this. The cheque is almost more important to me than the monetary value written on it.
Me with David Chan
Jan Soska, Jonathan Bailey, Kevin Guenay, Filippo Cassini, Jon Kraft (left to right)
My favourite Austrian engineer and me
Only got dessert after the award ceremony
If you want to watch the award ceremony itself, then I have uploaded it to YouTube:
Once I was done eating a bit at the stage and talking with some more people, I went back on the closing party floor, and the number of people who wanted a picture of me, my cheque and my medal, as well as congratulating me, was crazy and a nice ego boost as well.
I came across the Australians again, and after some talking, Australian banter, and an attempted joke at my height using the cheque, I had to leave, because food was waiting for me.
Shouldn’t have let it wait. Due to the ceremony and all the talking, it got so late that the food was already being taken away, and all I could grab for myself was a single Falafel slider. Sad, but that’s how it went.
After the closing party, it was time for the Goo Goo Dolls concert and before, during, and after, there was lots of picture taking and congratulating on the menu again, which I happily did.
The day ended with a helicopter tour across Las Vegas at night, which is a great experience and something I can easily recommend, and a casino visit, though I didn’t gamble. Again, an end at 01:00, so back to the usual sleep cycle.
The strip by night
Red balls always signal good things
Friday was the last day of Accelerate, but it’s just departing, and since my plane left at 16:40, I had lots of time to just walk around Las Vegas and do some last-minute shopping before I had to go to the airport. I have actually written this entire thing on my flight from Denver to Munich, though the editing and publishing were done in Austria.
And that’s it for the Fortinet Accelerate 2026 and my first showing at the global Ultimate Fabric Challenge.
The last exciting event of the week was actually at the airport in Vienna. My parents surprised me with a congratulatory banner right at the exit, which put another smile on my face and this, plus the dinner after, was the best end I could have hoped for.
What now?
As far as personal things are concerned, I need to take a bit of time to rest. I came into Accelerate overtrained, dieted down to my lowest weight yet and was stressed out due to the UFC. Week-long events are also quite exhausting, because you’re almost constantly on your feet, days are long, and nights are short.
When it comes to the blog, I already have the next topic planned. I actually got the idea while preparing for the UFC and had to stop myself from writing it because I needed to spend the time preparing for the competition. If you paid special attention while reading this post, you might be able to guess what the topic is going to be.
Until my next post comes out, I thank you, dear reader, for making it this far. This isn’t the type of post I want to make often, but it was a special occasion.
As I was looking at my notes to enhance my FortiGate baseline, before publishing it, I came across a Fortinet technical tip article I saved months ago that talks about making periodic hash checks of FortiGate files as a best practice.
This is definitely something you should do, but there really is only the command to list the hashes and as the article states:
“It is important to note that this command does not do any comparison on its own; it will simply provide the hashes. It is possible to take a known-good hash from the unit and save it in a secure place, then run this command periodically to compare against the known-good file.”
So the comparison is something you have to take care of, and since there are, on my 70G running 7.6.6, 3695 files, doing this manually is quite the task. To make this easier and more approachable, I wrote the FortiGate filesystem hash checker.
What is it?
The FortiGate filesystem hash checker is a Python script whose main purpose is to compare a known-good hash list of FortiGate system files with a new list of hashes. It highlights modifications, additions, and removals, or tells you that everything is in order.
To function, you first need to create a hash list, and you can either provide this as a raw file using the diagnose sys filesystem hash command or by having the script connect to the FortiGate via SSH, executing the command, grabbing the output and saving it to a file. Once you have both a known-good file and a new file, let the script do its comparison and optionally save the result.
The script can take in a CSV file so you can compare multiple FortiGates with a single script execution.
For a more in-depth explanation of the script and how to use it, please read the README.md on the GitHub repository.
If your FortiGate was ever attacked and compromised, it is possible that files were in some way changed, added or removed to gain persistence, deploy malicious code, exfiltrate data, etc. These file actions would be reflected in the hash changing, a new hash appearing or an existing one disappearing, and this can be an Indicator of Compromise (IoC). This has happened in the past, like with FG-IR-22-398.
Right now! There is no time like the present, and without a known-good baseline, you won’t ever know if something happened.
Once you have your known-good file, run the FortiGate filesystem hash checker:
Periodically, for some peace of mind
After you suspect a breach
Before an upgrade
After an upgrade, to build a new known-good file
Note that some file hash changes simply happen from time to time, and this is expected, because these are all files on the system, so certificates and related files, regularly updated files (signatures, ISDB, the geo IP database, etc.) and so on will also show up.
Wrapping up
I am aware that the FortiGate filesystem hash checker isn’t going to be for everyone, but if the script helps even one person, that’s time well spent as far as I’m concerned.
With that in mind, check out the GitHub repository, look at what’s there, and make your own decision about how useful it is to you. I hope you will only ever see a “Everything is OK. No changes detected between the old and new filesystem hash files.” message if you expect the worst.
Over the years, I have assembled my own baseline for FortiGates that I apply to basically every deployment. It contains sensible settings, some checks, best practices, and small things that not everyone knows about. I wanted it to be as generic as possible, so that it can be used for every situation. The baseline grew with experience and time, and I am finally getting around to sharing it with the world.
Points to consider
I am not liable for any issues you experience when using any parts of my baseline.
The baseline will get updates, but they will come when I get to it and when I feel the changes make sense.
I won’t edit this post when I update the baseline. I will only update the files on GitHub. I might, however, make a “State of the baseline…”-style post in the future if it is warranted.
I am assuming a few things for my baseline:
Internal and external traffic is handled on the same device. This is important for one point, which is highlighted.
No VDOMs are used. This doesn’t mean that the baseline can’t be used for VDOM deployments, but lots of things need to be handled differently if VDOMs are used.
An active internet connection is available. This isn’t necessary, but some things will not work without one.
Only IPv4 will be handled. No IPv6 settings are provided.
This is a CLI-first baseline. For some settings, both a GUI and a CLI way to configure exist. Due to the changing nature of the FortiGate GU, I will only provide the GUI way if necessary.
I am not infallible. If you believe the baseline can be improved, create an issue or a pull request on GitHub, and I’ll look at it.
The baseline should stay as generic as possible and shouldn’t interfere with existing configurations (performance impacts are highlighted, and decisions are up to the end user), so requests to change the baseline will have to keep this in mind. To give an example of what I wouldn’t accept: Deleting the SIP session helper and disabling the SIP ALG. While such a configuration is often required, it can create problems.
The GitHub project
Publishing the baseline on GitHub is a no-brainer to me because of version control, issues, pull requests, availability, etc.
The way the GitHub project is structured is as follows:
The README.md contains much of what I have written here
The baseline.md is the important file and holds multiple sections
Each section in the baseline.md covers one topic, includes a description, and the CLI commands to configure the relevant topic
A fortigate_baseline_no_input_safe.conf file is available that includes all safe CLI commands that can be immediately applied to a device and should be non-disruptive. The “no_input” refers to the fact that you don’t have to supply anything because, for some sections, you need to supply additional information, like interface names or services.
The only exception to the “safe” part is Private Data Encryption (PDE), because of how it interacts with FortiManager. This configuration includes PDE because it is highly recommended. PDE will always be the very first item.
A fortigate_baseline_no_input_safe_create.conf is available that does everything the “no_input” file does, but also creates objects (addresses, security profiles, threat feeds, etc.), but does not use them. All created objects have the prefix “BASE_”.
The activation of FortiGate Cloud Sandbox is also included in this, even though it requires input.
A fortigate_baseline.conf file is available that includes all CLI commands and has placeholders (e.g. “<###PLACEHOLDER_INTF###>”) for information you need to supply first.
That’s it
I genuinely hope that people find value in this baseline and that it grows with the input of everyone, to create better, more secure FortiGate installations.
Want to learn more about how to make FortiLink work over layer 2 with a third-party switch in the mix? Yes? Then let’s go!
Disclaimer
I want to stress that this type of configuration shouldn’t be your go-to. It’s something you do because you need it. Doing it “right” involves only FortiSwitches.
I have also experienced some issues with FortiSwitch’s DHCP client process during the quick version, where the DHCP traffic was sometimes blocked somewhere, but layer 2 data traffic worked. Changing the FortiSwitch’s internal interface to static and back to DHCP solved this issue. A port bounce also worked, but it is more disruptive. Not sure if this is a firmware issue on FortiSwitch or a problem with my third-party switch.
This should work with any enterprise third-party switch, because we only need VLANs, STP, and LACP.
In this article, I will focus on making this all work using the virtual internal interface, not the dedicated physical mgmt interface that some FortiSwitch models have (mainly because the physical interface makes this entire thing a non-issue).
The internal interface is a virtual in-band interface that is used for management purposes. You can use it for administrative access (SSH, HTTPS, etc.), and it’s also used to source the traffic that is relevant for FortiLink to function.
The quick version
If you just want to get this configuration working and you don’t intend on connecting an additional FortiSwitch to the one you configure, or you’re fine with doing some manual configuration later to do so, this section is for you. If you want to know more about how all of this works, the pitfalls, and scalability, you can skip the quick version sections.
The quick version: Third-party switch
On the third-party switch, you have two links, which for me are both LACP LAGs, but they can also be non-LACP and non-LAG links. These links should be 802.1Q trunks where the native VLAN on both links can be one you pick, unless it’s 4094. The detailed explanation further down expands on why not this one. The allowed VLANs should match on both links. This is everything we need on the third-party switch.
Third-party switch config (quick)
vlan 4084
name NATIVE
interface lag 1
description FORTIGATE-LAG
no shutdown
vlan trunk native 4084
vlan trunk allowed all
lacp mode active
interface lag 2
description FORTISWITCH-LAG
no shutdown
vlan trunk native 4084
vlan trunk allowed all
lacp mode active
interface 1/1/1
description FORTIGATE-LAG-MEMBER
no shutdown
lag 1
interface 1/1/2
description FORTISWITCH-LAG-MEMBER
no shutdown
lag 2
The quick version: FortiGate
I assume that you have at least the default “fortilink” interface on your FortiGate. This interface is enabled for FortiLink, has a DHCP server attached, and is configured as an NTP server. If you need to first create a FortiLink interface, please refer to the documentation.
On the FortiGate, our main objective is to prepare everything for the FortiSwitch, because we need to pre-authorize and pre-configure it. The reason is that once the FortiSwitch connects to the FortiGate via FortiLink, it will get created with a default configuration that will then get pushed onto the FortiSwitch. This default configuration won’t have any of the configuration we are going to make regarding the link to the third-party switch, so the FortiSwitch would pop up on the FortiGate and after the first config sync disappear, because it just loses its upstream connectivity.
First, we create the VLAN interface, which we will later use for the trunk. This VLAN interface just needs a name, the parent interface and the ID. We don’t need an IP or anything else:
Next, we pre-authorize the FortiSwitch via its serial number:
Note: For some reason, my pre-authorized FortiSwitch was created with a configured speed of 10Mbit/s half-duplex on all ports. This might be a weird version thing, but check this yourself and set it to “Auto-negotiation” (you need to enable the “Configured speed” column first if you do it in the GUI).
As a final step on the FortiGate, we configure the LACP trunk port and the VLANs:
FortiGate config (quick)
config system interface
edit "VL_4084"
set vdom "root"
set interface "fortilink"
set vlanid 4084
next
end
config switch-controller managed-switch
edit "S424ENTF12345678"
set sn "S424ENTF12345678"
set fsw-wan1-peer "fortilink"
set fsw-wan1-admin enable
next
end
config switch-controller managed-switch
edit "S424ENTF12345678"
config ports
edit "FORTILINK-L2"
set vlan "VL_4084"
set allowed-vlans-all enable
set type trunk
set mode lacp-active
set members "port2"
next
end
next
end
With that, our FortiSwitch will get the correct configuration once it connects.
The quick version: FortiSwitch
I am starting with a factory default FortiSwitch that has only a password set, so I can log in.
We are going to do much the same as on the FortiGate when preparing the FortiSwitch, so first, an LACP LAG trunk:
config switch trunk
edit "FORTILINK-L2"
set mode lacp-active
set members "port2"
next
end
Then we configure the VLANs for the internal interface and the trunk to match the third-party switch’s VLANs:
config switch interface
edit "internal"
set native-vlan 4084
next
edit "FORTILINK-L2"
set native-vlan 4084
set allowed-vlans 1-4094
next
end
As a last step, we disable the auto topology feature. More on that in the detailed sections:
config switch auto-network
set status disable
end
With these things, our FortiSwitch should show up on the FortiGate and be fully manageable.
And that’s it for the basics, but we’re not here for just the basics.
What even is FortiLink?
FortiLink is Fortinet’s proprietary protocol that is used to facilitate the management and onboarding of FortiSwitches. It is a combination of CAPWAP for management (there are alternatives for management), LACP for link establishment, LLDP for discovery, and some magic to tie all of it together and add more features on top of it.
FortiLink is used both between a FortiGate and a FortiSwitch and also between FortiSwitches. The idea is that you connect a FortiSwitch to designated FortiLink ports on either a FortiGate or another FortiSwitch, and all the configuration you need to onboard the FortiSwitch happens automatically, so you spend less time onboarding and more time doing the relevant configuration.
Since FortiLink is proprietary, it will not work with third-party switches, so they always present a challenge, and the go-to answer is FortiLink over layer 3. I see this as a configuration you do out of necessity, i.e. if you use if you don’t have a layer 2 connection. Layer 3 has some limitations, which you can read about in the linked piece of documentation, and a layer 2 connection shouldn’t have them. I write “shouldn’t” because I have only tested the “No layer-2 data path component… “ and “FortiSwitch NAC is not supported” piece and that worked (note that if you have are doing layer 2 with a model, like an 108F, that can’t be directly connected to the FortiGate to use FortiSwitch NAC you still need another FortiSwitch in between).
Why would one want to introduce a third-party switch?
There can be a multitude of reasons why you can have a third-party switch somewhere in your infrastructure, as well as a FortiSwitch. The most likely reason is because of a migration. Imagine that you have a FortiGate, some third-party switches in your core, and you are now, as a first step, replacing the third-party access switches with FortiSwitches (later you might replace the core switches with FortiSwitches, but that’s a project for future you). In such a scenario, you can fully manage the FortiSwitches via a layer 2 connection and take advantage of the features right now.
Is this stable? Does this survive a reboot?
Yes, and yes, but with a pitfall.
I had this running for over 3 hours, and the join time of the FortiSwitch didn’t change, and config syncs continued to function without problems.
The pitfall with reboots is a feature called auto topology, and this feature, and the impact it has, created hours of frustration for me in writing this post. In the quick version, this feature got disabled, so reboots aren’t a problem, but disabling it makes it so that Inter-Switch-Links (ISLs) don’t form automatically between FortiSwitches.
To understand why this feature creates problems, we have to first talk about how FortiLink uses VLANs, and then we get to auto topology.
How FortiLink uses VLANs
By default, the FortiLink VLAN on FortiOS is 4094. A FortiGate sends FortiLink communication untagged out of FortiLink interfaces, but, and this is really important, on the FortiSwitch side, this traffic, sourced by the virtual internal interface, egresses VLAN tagged out of the physical interface. This particular tagged VLAN behaviour is only relevant in a layer 2 configuration. In a regular FortiLink configuration, 4094 gets set as the native VLAN on ISLs.In practice, this means that the native VLAN of the internal interface needs to be covered by the allowed VLANs of the FortiSwitch <-> third-party switch trunk.
The native VLAN that will be configured on the FortiSwitch <-> third-party switch trunk should match, and the native VLAN on the internal interface needs to match the FortiGate’s native VLAN, which is also the FortiLink VLAN, on the third-party switch’s trunk to the FortiGate. These two VLANs (native trunk VLAN and FortiLink VLAN) must be different. You can change the FortiLink VLAN on the FortiGate if you want, and if you want scalability for a layer 2 configuration with a third-party switch, you almost have to.
config system interface
edit fortilink
set switch-controller-mgmt-vlan XXXX
next
end
This VLAN configuration will probably sound a bit confusing, so here is a diagram, with an adjusted FortiLink VLAN, to make it easier to understand:
You can pick any VLAN as the native VLAN on the FortiSwitch <-> third-party switch trunk, and again, it must be different from the FortiLink VLAN. “Any” isn’t strictly true, because 4094, on a default configuration, creates some problems in this type of layer 2 configuration, and I would also not use the 4088-4093 range, because FortiOS creates VLAN interfaces with these IDs for things like quarantine, RSPAN, etc. You can use these VLAN interfaces for the following configuration, but it’s ugly.
I have tested changing the FortiLink VLAN in a regular FortiLink environment, where only FortiSwitches exist, and this didn’t create any connectivity problems, by the way, so this isn’t a dealbreaker when introducing layer 2 FortiSwitches behind a third-party switch.
Now to auto topology.
On auto topology
The auto topology (or auto-network as it’s called in FortiSwitchOS) feature is enabled starting with FortiSwitchOS 7.2 and enables the auto-forming of ISLs between FortiSwitch units. This makes it easier to create a FortiSwitch topology because it turns into a plug-and-play scenario.
On a default configuration, the management VLAN for auto topology on a FortiSwitch is set to 1, and once a FortiSwitch connects to a FortiGate, the value will be set to whatever the FortiGate has configured on its FortiLink interface on which the FortiSwitch connects.
Auto topology also sets the native VLAN on the ISLs to the management VLAN.
As an aside, auto topology might also be something you need to disable, or change the management VLAN of, on a standalone FortiSwitch deployment if your internal interface needs a different native VLAN than 1.
In the quick version, we disabled auto topology. If you had it enabled, the native VLAN of the internal interface would get changed to the FortiLink VLAN defined on the FortiGate, and this breaks connectivity. Disabling auto topology creates another problem, which becomes clear when you read the documentation of the feature:
“Use the auto topology feature to automatically form an inter-switch link (ISL) between two switches.”
If we disable it, ISLs don’t get formed automatically between FortiSwitches, so we want auto topology enabled.
If you look at the diagram above, I used VLAN 4083 for FortiLink, so you might ask:
Why not VLAN 4094?
This all boils down to two quirks of how FortiOS handles the 4094 VLAN and also the FortiLink VLAN.
You cannot create a VLAN interface that matches the FortiLink VLAN, and you can’t change the FortiLink VLAN to the ID of an existing VLAN interface.
Setting allowed VLANs to “all” on a FortiGate-managed FortiSwitch actually means a few different things, but never includes 4094.
Point 1 is simple. If we could have a VLAN interface with ID 4094, we could configure this as the native VLAN everywhere, and this would be very simple, but without this interface, we can’t set the native VLAN on the FortiSwitch trunk (remember the FortiGate configures this trunk).
Point 2 is devilish. FortiOS uses VLAN optimization for VLAN pruning. This is a nice feature, but the way it works is strange and also wrong. It’s wrong because it’s described as “applicable only to inter-switch link (ISL/ICL)”, but, from my testing, it also affects trunks you create manually, so it affects the configuration of our managed FortiSwitch.
If you disable VLAN optimization you get a new option called “vlan-all-mode” with the options “defined” and “all”. “defined” sets the VLANs to whatever you have configured as FortiLink VLANs on the FortiGate. “all” should cover the full VLAN range of 1 to 4094, but the documentation, and the description in FortiOS, says “Include all possible VLANs (1-4093).”.
Notice how 4094 is missing from there. This absence is unrelated to the FortiLink VLAN. If you set it to something else, 4094 is still missing.
Thanks to these two points, the FortiLink VLAN of 4094 creates the following chain of events on a default configuration:
You have a working layer 2 configuration, and your FortiSwitch connects to the FortiGate
The configuration gets synced to the FortiSwitch
The auto-network/auto topology “mgmt-vlan” gets set to 4094
The native VLAN of the internal interface gets set to 4094
The allowed VLANs of the FortiSwitch trunk to the third-party switch get set to your configured VLANs, which never include 4094
If you disable VLAN optimization and set “vlan-all-mode” to “all”, the allowed VLANs are 1-4093
The FortiSwitch loses connectivity and never regains it
Remember, the internal interface’s native VLAN gets sent out as tagged traffic on the physical interface.
The auto topology feature, plus the way VLAN 4094 is handled, also creates problems with the plug-and-play functionality when you have another FortiSwitch connected to your layer 2-configured FortiSwitch.
How to make this plug-and-play with another FortiSwitch
I will now go through the entire configuration to enable the plug-and-play functionality you expect from FortiLink, but with a third-party switch directly connected to the FortiGate.
Plug-and-play: FortiGate
FortiGate config (plug-and-play)
config system interface
edit "VL_4084"
set vdom "root"
set interface "fortilink"
set vlanid 4084
next
end
config system interface
edit fortilink
set switch-controller-mgmt-vlan 4083
next
end
config switch-controller global
set vlan-optimization none
set vlan-all-mode all
end
config switch-controller managed-switch
edit "S424ENTF12345678"
set sn "S424ENTF12345678"
set fsw-wan1-peer "fortilink"
set fsw-wan1-admin enable
next
end
config switch-controller managed-switch
edit "S424ENTF12345678"
config ports
edit "FORTILINK-L2"
set vlan "VL_4084"
set allowed-vlans-all enable
set type trunk
set mode lacp-active
set members "port2"
next
end
next
end
We create the VLAN 4084 that gets used for the FortiSwitch’s native VLAN on the trunk
We change the FortiLink management VLAN to 4083
We disable VLAN optimization and set the allowed VLANs to all (1-4093)
We pre-authorize the FortiSwitch
We configure the FortiSwitch trunk
The VLANs I picked can be any VLANs of your choosing, but all the caveats I explained still apply. It boils down to “Don’t use 4088-4094 and use different VLANs for each function”.
Plug-and-play: Third-party switch
Third-party switch config (plug-and-play)
vlan 4083
name FORTILINK-MGMT
vlan 4084
name FORTISWITCH-NATIVE
interface lag 1
description FORTIGATE-LAG
no shutdown
vlan trunk native 4083
vlan trunk allowed all
lacp mode active
interface lag 2
description FORTISWITCH-LAG
no shutdown
vlan trunk native 4084
vlan trunk allowed all
lacp mode active
interface 1/1/1
description FORTIGATE-LAG-MEMBER
no shutdown
lag 1
interface 1/1/2
description FORTISWITCH-LAG-MEMBER
no shutdown
lag 2
We create a VLAN for FortiLink management and one for the FortiSwitch trunk
We create an LACP LAG for the FortiGate with a native VLAN of 4083, which is the FortiLink management VLAN, and allow all VLANs
We create an LACP LAG for the FortiSwitch with a native VLAN of 4084 and allow all VLANs
We assign the physical ports to each LAG
Plug-and-play: FortiSwitch
FortiSwitch config (plug-and-play)
config switch trunk
edit "FORTILINK-L2"
set mode lacp-active
set members "port2"
next
end
config switch interface
edit "internal"
set native-vlan 4083
next
edit "FORTILINK-L2"
set native-vlan 4084
set allowed-vlans 1-4094
next
end
We create an LACP trunk with a member port
We configure the internal interface with the native VLAN of 4083, which matches the FortiLink VLAN
We configure the LACP LAG with a native VLAN of 4084 and allow all VLANs
With this configuration, your layer 2 FortiSwitch will connect without issues to the FortiGate, sync configurations, and if you connect another FortiSwitch to the configured FortiSwitch, an ISL will automatically form, and it will just work.
Again, the questions: Is this stable? Does this survive a reboot?
Yes and yes. No caveats.
I have also tested this with a third FortiSwitch hanging of the new FortiSwitch and didn’t notice any issues.
I want to manually connect another FortiSwitch
Let’s say you don’t care about all this plug-and-play stuff and the quick version is enough for you, but you still need another FortiSwitch connected and manageable. This requires a bit more legwork, but it is doable.
There are five basic steps you have to do:
Pre-authorize the new FortiSwitch (FortiGate)
Manually create the trunk interfaces on both FortiSwitches with the same configuration on the FortiGate (FortiGate)
Manually create the trunk interface with the correct VLANs on the new FortiSwitch (FortiSwitch)
Disable auto topology on the new FortiSwitch (FortiSwitch)
Manually set the native VLAN of the new FortiSwitch’s internal interface (FortiSwitch)
FortiGate second FortiSwitch config (quick, steps 1-2)
config switch-controller managed-switch
edit "S108FPTV123456768"
set sn "S108FPTV123456768"
set fsw-wan1-peer "fortilink"
set fsw-wan1-admin enable
next
end
config switch-controller managed-switch
edit "S424ENTF12345678"
config ports
edit "FLINK-MANUAL"
set vlan "VL_4084"
set allowed-vlans-all enable
set type trunk
set mode lacp-active
set members "port1"
next
end
next
edit "S108FPTV123456768"
config ports
edit "FLINK-MANUAL"
set vlan "VL_4084"
set allowed-vlans-all enable
set type trunk
set mode lacp-active
set members "port1"
next
end
next
end
FortiSwitch second FortiSwitch config (quick, steps 3-5)
config switch auto-network
set status disable
end
config switch trunk
edit "FLINK-MANUAL"
set mode lacp-active
set members "port1"
next
end
config switch interface
edit "internal"
set native-vlan 4084
next
edit "FLINK-MANUAL"
set native-vlan 4084
set allowed-vlans 1-4094
next
end
Again, the questions: Is this stable? Does this survive a reboot?
Yes and yes.
What if my in-between switch is another FortiSwitch?
Weird ask, but okay.
If you happen to have a FortiSwitch as your “third-party switch”, because it’s standalone, or managed by another FortiGate, the same configuration regarding VLANs, links (LACP or not), and trunks applies as with any other third-party switch, but you have to change the LLDP profile on your FortiSwitch’s port connecting to this third-party FortiSwitch from the default “default-auto-isl” profile to one without auto-ISL enabled, like the “default” profile. The third-party FortiSwitch doesn’t need to make any LLDP profile changes for this to work.
config switch physical-port
edit port2
set lldp-profile default
next
end
Again, the questions: Is this stable? Does this survive a reboot?
Yes and yes. No caveats.
More third-party switches
I have also tested the following topology:
Not much to say here. It works.
Again, the questions: Is this stable? Does this survive a reboot?
Yes and yes. No caveats.
Wrapping up
I wanted to make this post because I have not seen this topic being covered by anyone before, and it’s something that comes up from time to time, and most of the information on how to make it work is in a Reddit comment here, a Discord post there, etc. Now it’s hopefully condensed and easily accessible.
I initially thought that this was going to be a quick thing that would take me about four hours from start to finish. In the end, it took me about 16 hours, because while getting the quick version done, the question “What if I want to add another FortiSwitch?” popped into my head and that sent me down the rabbit hole of auto topology, VLAN 4094 and more topology examples.
I hope that this entire thing was interesting and hopefully helpful to you, dear reader.
Zero Trust Network Access (ZTNA) is a fun term that, in 2025, is barely a buzzword and is ready to join the modern, established terms like SD-WAN, 2FA, and IoT.
There isn’t a real consensus in the networking and security industry on what defines ZTNA, but if you ask most vendors, you will probably get at least three points, in different words:
The client is initially not trusted, so he has no default access to anything. This is the Zero Trust part.
Connections are made on an application basis, meaning that a client requests access to a specific resource, not an entire subnet.
The client is checked before and during the connection to see if he is still allowed to access the resource. This is client posturing.
I have a problem with the “Zero” part of ZTNA, because if there is no trust, there can be no connection. You need to trust someone to gain access to something. I once talked with Jaye Tillson, and he prefers to call it “Adaptive Trust Network Access”, which is a much better term, and it actually describes how it is being employed in the field.
With that preamble out of the way, let’s see how Fortinet does ZTNA connections for TCP forwarding.
I want to stress that this is not a configuration guide, and I assume that you, dear reader, know the basics of how Fortinet does ZTNA. Please refer to the documentation to see how to set all of this up yourself. I will focus on the actual traffic flow and the underlying process.
The setup
I have:
A client outside that will be called FortiClient or client, which wants to access a resource.
A FortiGate that acts as the ZTNA access proxy, called ztnaproxy.ad.labdomain.com.
A FortiClient EMS server called ems.ad.labdomain.com, which is accessed via a regular DNAT/VIP.
A server on the LAN called win-server.ad.labdomain.com, which will get accessed via a ZTNA connection, as well as a server called win-ad.ad.labdomain.com, which is just there for a demonstration.
As far as Fortinet versions are concerned, I’m using the, at the time of writing, latest and greatest versions. FortiOS 7.6.4, FortiClient 7.4.4, and FortiClient EMS 7.4.4.
Let’s start, and we start where it all begins, the client.
How FortiClient does ZTNA
A FortiClient receives its ZTNA configuration from EMS, and the first decision that is made is what you’re trying to connect to, even a ZTNA destination? The way this happens is straightforward. FortiClient will check whether whatever the client is trying to access is in its ZTNA destination list, and if there is a match, it goes ahead with everything else.
What we see here is ZTNA destinations with both FQDNs and an IP, which leads to the next question: Who does the DNS resolution for the FQDNs?
The answer is FortiClient itself.
In the past, the hosts file was used, which was ugly, but with new versions of FortiClient, the FortiTCS (Tunnel Control Service) process takes care of this. It takes the ZTNA destination configuration from EMS, records the configured settings, assigns IP addresses for DNS resolution, and runs its own DNS proxy. It handles a lot more, but we’ll get to that.
If you want to see FortiTCS at work, check out the log file at “C:\Program Files\Fortinet\FortiClient\logs\trace\fortitcs_X.log” (it’s a rolling log, so make sure you look at the most recent file). I will display logs from there extensively, starting with a ZTNA configuration update:
This log was taken right after an update, signalized by the “Configuration Changed!” message right at the top.
We can see each ZTNA destination being listed, and for the destinations that are FQDNs, we have the “IPStart” and “IPEnd” fields being filled with what are effectively bogus IP addresses from the 10.235.0.0/16 subnet (I assume it’s /16, but I’m not going to test this). This subnet is, to my knowledge, hardcoded, and you might ask, “What happens if there is an overlap in my network with this?” Well, you’ll have problems when doing FQDN ZTNA destinations, so pray that you don’t have this subnet in use.
And if you’re curious, entries like “Ip=183173121” and “IpStart: 16843009” are IPs converted to decimal. If you math this out, you get 10.235.0.1 and 1.1.1.1 respectively. The “DOH Forwarding” entries all seem to be DNS over HTTPS (DoH) servers, but I don’t know why they are in there.
Circling back, if the client tries to access an FQDN ZTNA destination, FortiClient intercepts the DNS request and returns a bogus IP. This happens regardless of whether the connection is relevant to ZTNA or not. Even a ping, and ICMP isn’t supported by Fortinet’s ZTNA implementation, gives you the 10.235.0.0/16 IP.
If the ZTNA destination is an IP, nothing special happens; it simply gets forwarded, and this is where FortiTCS comes in again.
FortiTCS is responsible for establishing a connection to the FortiGate, which acts as the ZTNA access proxy, aka ZTNA server, aka ZTNA Forwarding Access Proxy (TFAP), aka ZTNA gateway (I think that are all the names you see in the documentation), and processing and forwarding the relevant traffic via a secure connection.
Looking at fortitcs_X.log again, we see the following log entry if an FQDN ZTNA destination is accessed:
For the sake of brevity, I have removed some lines that are unimportant, and I will do this with all logs and debugs in this article. You’re welcome.
As we can see in the log, it’s a connection for the IP 10.235.0.1, on port 3389, and the FQDN is win-server.ad.labdomain.com. We also see the decimal IP 183173121 here again. The gateway IP, or actually FQDN, is ztnaproxy.ad.labdomain.com, but the actually interesting thing is the following line:
Request: GET /tcp?address=win-server.ad.labdomain.com&port=3389&tls=0 HTTP/1.1
FortiClient’s ZTNA connection is nothing more than an HTTP GET request towards the FortiGate, and this happens over an encrypted tunnel.
The URL being accessed says what resource we’re trying to access over what port, and the tls parameter determines if this is getting extra encryption, but more on that later. Some headers are being sent with that, including the Host header, which is not important for this type of ZTNA connection, because we’re doing TCP forwarding.
Note how the URL uses the FQDN as the address parameter. The FortiGate does not get the IP in this case, and considering it would be a bogus IP, that is for the better. The FortiGate needs to be able to resolve this FQDN itself so it can establish a connection with the correct real server.
The same type of connection with an IP instead of an FQDN is much the same, except that there is no DNS stuff going on.
For the time being, that’s everything on the FortiClient’s side, so let’s check what the FortiGate does with all the information FortiTCS provides.
How FortiGate does ZTNA
In Fortinet’s ZTNA construct, a FortiGate acts as a proxy server, meaning it receives connections from FortiClient and connects to the real server on behalf of the client. How does it do that?
FortiOS handles the ZTNA feature in the Web Application Daemon (WAD) process, so if we want to know what happens, we have to look at the debugs of WAD. To see the relevant WAD debug categories for ZTNA and enable debugging, you can use the following commands:
If you don’t want to miss anything, you can also turn on everything WAD has to offer:
diagnose wad debug enable category all
diagnose debug enable
Let’s see what a ZTNA FQDN connection looks like in the debugs:
FortiGate ZTNA FQDN debug
[I][p:3335][s:41045] wad_vs_ssl_port_caps_c2p_on_client_hello:11217 675:ZTNA-LAB: wsp(0x7fa1a4b440) handshake recv ClientHello record 3.1 client 3.3 supported 3.3
[V][p:3335][s:41045] wad_vs_proxy_match_vhost :4662 675:ZTNA-LAB: matching vhost by: ztnaproxy.ad.labdomain.com
[V][p:3335][s:41045] wad_vs_matcher_map_find :798 Empty matcher!
[V][p:3335][s:41045] wad_vs_proxy_match_vhost :4665 675:ZTNA-LAB: no host matched.
[I][p:3335][s:41045] wad_vs_ssl_port_caps_cert_req :14077 675:ZTNA-LAB: requested certs from client. ca_store is ready.
[I][p:3335][s:41045] wad_vs_ssl_c2p_cert_req_add_trust_issuers:14038 675:ZTNA-LAB: adding subject: C = CA, ST = California, L = Sunnyvale, O = Fortinet, OU = 00000000000000000000000000000000, CN = FCTEMS8824011503 to CCR.
[I][p:3335][s:41045] wad_vs_ssl_c2p_cert_req_add_trust_issuers:14032 675:ZTNA-LAB: cas->x509 is (nil) or ca list is empty.
[I][p:3335][s:41045] wad_vs_ssl_access_proxy_on_clt_certs:13476 675:ZTNA-LAB: received certs from the client.
[I][p:3335][s:41045] __wad_ssl_cert_open_cert :641 https server uses key_len 2048
[I][p:3335][s:41045] wad_ssl_cert_check_auth_status_with_ca_store:464 authi(0x7fa1ec2570) status(0)
[I][p:3335][s:41045] wad_ui_ssl_cert_verify :1562 Certificate verified!
[I][p:3335][s:41045] wad_ssl_cert_parse_cert :1448 EMS SN=FCTEMS8824011503
[I][p:3335][s:41045] wad_ssl_cert_parse_cert :1453 Tenant ID=00000000000000000000000000000000
[I][p:3335][s:41045] wad_ssl_cert_get_sn :1364 ci(0x7fa1b10eb8): sn=3AA3BF4801BF5771BF3079CCDFB7F66DF0841E6F
[I][p:3335][s:41045] __wad_ssl_validate_ztna_cert_by_ca_store:3465 Successfully verified cert by CA.
[I][p:3335][s:41045] wad_vs_ssl_access_proxy_on_clt_certs:13530 675:ZTNA-LAB: Cert auth success. issued_by: ems
[I][p:3335][s:41045] wad_vs_ssl_port_caps_c2p_on_handshake_done:12075 wsp 0x7fa1a4b440 type 10 handshake done
[I][p:3335][s:41045] wad_vs_http_make :5500 make vs http session
[V][p:3335][s:41045][r:33554433] wad_http_req_alloc :1911 req=0x7fa2aa1048(ses_ctx:ct|Pv|M|H|C|A1|O) dst(orig=172.31.1.1:443 dst=172.31.1.1:443 srv=172.31.1.1:443) hs=0x7fa33e9228 clt_port=0x7fa1a4b6a0 svr_port=(nil)
[I][p:3335][s:41045][r:33554433] wad_dump_http_request :3066 hreq=0x7fa2aa1048 Received request from client: 172.31.1.2:51280
GET /tcp?address=win-server.ad.labdomain.com&port=3389&tls=0 HTTP/1.1
Host: ztnaproxy.ad.labdomain.com
User-Agent: Forticlient
Accept: /
Connection: Upgrade
Cookie:
Upgrade: tcp-forwarding/1.0
X-Fos-Auth:
[V][p:3335][s:41045][r:33554433] wad_http_marker_uri :1526 path=/tcp len=4
[V][p:3335][s:41045][r:33554433] wad_http_parse_host :1904 host_len=26
[I][p:3335][s:41045][r:33554433] wad_http_parse_host :1941 host=[26]ztnaproxy.ad.labdomain.com
[I][p:3335][s:41045][r:33554433] wad_http_str_canonicalize :2466 enc=0 path=/tcp len=4 changes=0
[I][p:3335][s:41045][r:33554433] wad_http_str_canonicalize :2468 end=4 path=address=win-server.ad.labdomain.com&port=3389&tls=0 len=51 changes=0
[V][p:3335][s:41045][r:33554433] wad_http_normalize_uri :2705 host_len=26 path_len=4 query_len=51
[I][p:3335][s:41045][r:33554433] wad_http_req_detect_special :14378 captive_portal detected: false, preflight=(null)
[I][p:3335][s:41045][r:33554433] wad_vs_proxy_match_gwy :4543 675:ZTNA-LAB: matching gwy with vhost(def_virtual_host)
[V][p:3335][s:41045][r:33554433] wad_vs_proxy_match_vhost :4662 675:ZTNA-LAB: matching vhost by: ztnaproxy.ad.labdomain.com
[V][p:3335][s:41045][r:33554433] wad_vs_matcher_map_find :798 Empty matcher!
[V][p:3335][s:41045][r:33554433] wad_vs_proxy_match_vhost :4665 675:ZTNA-LAB: no host matched.
[I][p:3335][s:41045][r:33554433] wad_vs_proxy_match_gwy :4611 675:ZTNA-LAB: matching gwy by (/tcp) with vhost(def_virtual_host).
[I][p:3335][s:41045][r:33554433] wad_vs_proxy_match_gwy :4630 675:ZTNA-LAB: Matched gwy(1) type(tcp-fwd).
[I][p:3335][s:41045][r:33554433] wad_http_srv_selector_static_make :1942 make static server selector.
[I][p:3335][s:41045][r:33554433] wad_vs_gwy_tcp_dst_ovrd :3382 675:ZTNA-LAB:1: req(0x7fa2aa1048) query(address=win-server.ad.labdomain.com&port=3389&tls=0)
[I][p:3335][s:41045][r:33554433] wad_vs_gwy_tcp_get_parameters :3136 675:ZTNA-LAB:1: got fqdn=win-server.ad.labdomain.com.
[V][p:3335][s:41045][r:33554433] wad_vs_matcher_map_find :798 Empty matcher!
[W][p:3335][s:41045][r:33554433] wad_vs_proxy_dns_resolve :3290 req(0x7fa2aa1048) vs DNS request name=win-server.ad.labdomain.com len=27 type/pref=0/0
[V][p:3335][s:41045][r:33554433] wad_http_mstrm_ops_read_block :1428 strm paused, flag=0x0 is_clt=1
[V][p:3335][s:41045][r:33554433] wad_http_clt_read_sync :2955 hs=0x7fa33e9228 pause=(1/0x0) ret=1 execute=wad_http_clt_read_req_line
[I][p:3335][s:41045][r:33554433] wad_vs_proxy_dns_request_done :3237 req(0x7fa2aa1048) vs DNS resolved: 192.168.1.241
[V][p:3335][s:41045][r:33554433] wad_http_mstrm_ops_read_block :1436 strm resumed, execute=wad_http_clt_read_req_line is_clt=1
[I][p:3335][s:41045][r:33554433] wad_http_req_exec_on_vs_dns_ready :12441 req(0x7fa2aa1048) vs DNS ready: dns_resolved(1), domain_matched(0), addr_matched(1)
[V][p:3335][s:41045][r:33554433] wad_http_req_get_dst_intf :12196 vd=0 dst=192.168.1.241 ifidx=5
[V][p:3335][s:41045][r:33554433] wad_http_req_vs_check_policy :12369 HTTP req=0x7fa2aa1048 out_intf=5, vwl=0
[V][p:3335][s:41045][r:33554433] wad_http_req_check_policy_with_flags:12021 start match policy vd=0(ses_ctx:ct|Pv|M|H|C|A1|O) (172.31.1.2:51280@8->192.168.1.241:3389@5) absUrl=0
[V][p:3335][s:41045][r:33554433] wad_fw_addr_match_ap :1292 matching ap:ZTNA-LAB(8) with vip addr:ZTNA-LAB(8)
[I][p:3335][s:41045][r:33554433] wad_http_policy_match_one :501 fw_pol_id=123(pol_ctx:th|Ad|7|=p) pflag:H|W|U|A asyn_info=1
[I][p:3335][s:41045][r:33554433] wad_fw_policy_async_match :5929 pol_ctx:th|Ad|7|=d
[I][p:3335][s:41045][r:33554433] wad_http_req_policy_set :10156 match policy-id=123(pol_ctx:th|Ad|7|=d) vd=0(ses_ctx:ct|Pv|Me|H|C|A1|O) (172.31.1.2:51280@8 -> 192.168.1.241:3389@5)
[V][p:3335][s:41045][r:33554433] wad_https_ap_pol_info_get :9957 policy info created, req=0x7fa2aa1048, ses_ctx=0x7fa1d4f0c8, info=0x7fa1eaf958
[I][p:3335][s:41045][r:33554433] wad_http_req_proc_policy :9635 ses_ctx:ct|Pvx|Me|H|C|A1|O conn_srv=0 fwd_srv=
[I][p:3335][s:41045][r:33554433] wad_http_req_proc_policy :9770 policy result:vf_id=0:0 sec_profile=0x7fa1d7c9c8 set_cookie=0
[I][p:3335][s:41045][r:33554433] wad_http_req_domain_front_chk :9563 chk uri host res=pass (is_abs=0 http_encap=0 connect=0): ztnaproxy.ad.labdomain.com
[V][p:3335][s:41045][r:33554433] wad_http_session_disconn_srv :1659 hcs=0x7fa33e9228 http_svr=(nil)
[I][p:3335][s:41045][r:33554433] wad_http_connect_srv :852
http ses=0x7fa33e9228 req=0x7fa2aa1048 ses_ctx=0x7fa1d4f0c8
nontp(0) dst_type(3)
req: dst:172.31.1.1:3389, proto:10)
connect svr orig 172.31.1.2:51280->172.31.1.1:443 out 172.31.1.2:0->172.31.1.1:443
[I][p:3335][s:41045][r:33554433] wad_http_upd_ses_ctx_by_req :948 wad http session 0x7fa33e9228 forward (nil) fwd_srv_ip=
[V][p:3335][s:41045][r:33554433] wad_http_connect_srv :875 [0x7fa2aa1048] Connect to server: 192.168.1.241:3389/192.168.1.241:3389
[I][p:3335][s:41045][r:33554433] wad_http_ses_ctx_find_conn_port :653 ssi: 0x7fa34d0c08 is looking for conn_port by vf:0 from:any to:192.168.1.241:3389 outif:-1
[I][p:3335][s:41045][r:33554433] wad_http_ses_ctx_find_conn_port :653 ssi: 0x7fa34d0c08 is looking for conn_port by vf:0 from:any to:192.168.1.241:3389 outif:-1
[V][p:3335][s:41045][r:33554433] wad_http_mstrm_ops_read_block :1428 strm paused, flag=0x0 is_clt=1
[V][p:3335][s:41045][r:33554433] wad_http_req_get_svr :7985 http session 0x7fa33e9228 req=0x7fa2aa1048 connecting
[V][p:3335][s:41045] wad_vs_http_server_connected :5484 port=0x7fa1abe2d8
[V][p:3335] wad_http_msg_start_setup_proc :2131 msg(0x7fa2aa1048) proc-setup started from: req_vs.
[V][p:3335] wad_http_def_proc_msg_plan :2093 msg(0x7fa2aa1048) setting up processor(req_vs)
[I][p:3335] wad_http_req_check_vs_tunnel_type :3319 try to match HTTPS/HTTP/SSH/CIFS/DOT with nport=3389
[I][p:3335] wad_http_req_check_vs_tunnel_type :3349 port=3389, proto=-1, protocol not matched.
[I][p:3335] wad_http_req_check_vs_tunnel_type :3359 Check redir PROXY port=3389((null))
[I][p:3335] wad_http_req_check_vs_tunnel_type :3367 TCP tunnel detected without type.
[V][p:3335] wad_http_req_convert_tfap_tunnel :3211 req(0x7fa2aa1048) is converting to VS forwarding tunnel.
[V][p:3335] wad_http_build_tfap_resp :653 ua=Forticlient
[I][p:3335] wad_http_req_set_tfap_param :3193 req:0x7fa2aa1048 got tunnel param type:unknown proto:tcp off_ssl:1
[I][p:3335][s:41045][r:33554433] wad_dump_fwd_http_resp :3087 hreq=0x7fa2aa1048 Forward response from Internal:
HTTP/1.1 101 Switching Protocols
Upgrade: tcp-forwarding/1.0
Connection: Upgrade
[I][p:3335][s:41045][r:33554433] __wad_http_req_close :2053 ret = -1!
[I][p:3335][s:41045][r:33554433] wad_http_clt_turn_off_ssl :3697 replace SSL transport.
[I][p:3335][s:41045][r:33554433] wad_http_strm_turn_ssl_off :3324 waiting for SSL port(0x7fa1a4b6a0) to close.
[I][p:3335][s:41045][r:33554433] wad_http_clt_turn_off_ssl :3726 wait for SSL port.
[I][p:3335][s:41045][r:33554433] wad_http_sstrm_off_ssl_for_tunnel :3647 Server is ready to drain SSL data.
[I][p:3335][s:41045][r:33554433] wad_http_req_convert_tfap_tunnel :3256 Wait for tunnel convertion.
[I][p:3335][s:41045] wad_vs_http_server_connected :5487 vs http server connected
The very first thing that happens after the ClientHello is that the FortiGate tries to find a virtual host, but since we’re doing TCP forwarding, not HTTP(S) access proxy, we don’t care about this part, and “no host matched” tells us there is nothing to be found.
When FortiTCS connects to the FortiGate, it sends not only the HTTP request to the FortiGate, but also, upon request, the certificate that the logged-in user gets from FortiClient EMS when FortiClient first connects as that user. By default, the access proxy on the FortiGate has “client-cert” enabled, so it must verify the certificate, and for that the EMS ZTNA root CA certificate is used:
[I][p:3335][s:41045] wad_vs_ssl_port_caps_cert_req :14077 675:ZTNA-LAB: requested certs from client. ca_store is ready.
[I][p:3335][s:41045] wad_vs_ssl_c2p_cert_req_add_trust_issuers:14038 675:ZTNA-LAB: adding subject: C = CA, ST = California, L = Sunnyvale, O = Fortinet, OU = 00000000000000000000000000000000, CN = FCTEMS8824011503 to CCR.
[I][p:3335][s:41045] wad_vs_ssl_c2p_cert_req_add_trust_issuers:14032 675:ZTNA-LAB: cas->x509 is (nil) or ca list is empty.
Once the certificate has been sent, the serial number on the certificates gets verified, and the serial number matches what we see in EMS (ZTNA Serial Number):
After that, we see the incoming HTTP request that FortiTCS has forwarded, including the headers, which is basically the same thing we saw in the FortiClient section.
We get some not-so-important information about the path, host length, virtual host matching, and then we see the FortiGate performing the DNS resolution for the FQDN that was supplied in the HTTP request:
With the supplied information, it matched the policy with ID 123 (I specifically set this ID to make it more visible).
Note that I am using proxy policies for ZTNA connections, since that is the recommended way, and from experience, it works better.
We found a policy, and as long as the policy action is accept the FortiGate connects to the real server:
[V][p:3335][s:41045][r:33554433] wad_http_connect_srv :875 [0x7fa2aa1048] Connect to server: 192.168.1.241:3389/192.168.1.241:3389
With default settings, the FortiGate will perform SNAT using the interface IP of the “closest” interface to the real server. “Closest” in this case means according to the routing table. If the real server is on a directly connected interface, then that interface’s IP will be used. If the server is reachable via another route, then the interface on which that traffic would egress will be used.
A packet capture on the real server shows this:
You can overwrite this behaviour on the CLI by using an IP pool:
config firewall ippool
edit "ZTNA-POOL"
set startip 192.168.1.251
set endip 192.168.1.251
next
end
config firewall proxy-policy
edit 123
set poolname "ZTNA-POOL"
next
end
After that, we can see that the real server sees the IP from the pool:
Finishing up the debug, we can see the FortiGate trying to match some protocols, then a conversion to a Virtual Server (VS) forwarding tunnel, and it ends with a “vs http server” connected message.
[I][p:3335] wad_http_req_check_vs_tunnel_type :3319 try to match HTTPS/HTTP/SSH/CIFS/DOT with nport=3389
[I][p:3335] wad_http_req_check_vs_tunnel_type :3349 port=3389, proto=-1, protocol not matched.
[I][p:3335] wad_http_req_check_vs_tunnel_type :3359 Check redir PROXY port=3389((null))
[I][p:3335] wad_http_req_check_vs_tunnel_type :3367 TCP tunnel detected without type.
[V][p:3335] wad_http_req_convert_tfap_tunnel :3211 req(0x7fa2aa1048) is converting to VS forwarding tunnel.
[V][p:3335] wad_http_build_tfap_resp :653 ua=Forticlient
[I][p:3335] wad_http_req_set_tfap_param :3193 req:0x7fa2aa1048 got tunnel param type:unknown proto:tcp off_ssl:1
[I][p:3335][s:41045][r:33554433] wad_dump_fwd_http_resp :3087 hreq=0x7fa2aa1048 Forward response from Internal:
HTTP/1.1 101 Switching Protocols
Upgrade: tcp-forwarding/1.0
Connection: Upgrade
[I][p:3335][s:41045][r:33554433] wad_http_strm_turn_ssl_off :3324 waiting for SSL port(0x7fa1a4b6a0) to close.
[I][p:3335][s:41045][r:33554433] wad_http_clt_turn_off_ssl :3726 wait for SSL port.
[I][p:3335][s:41045][r:33554433] wad_http_sstrm_off_ssl_for_tunnel :3647 Server is ready to drain SSL data.
[I][p:3335][s:41045][r:33554433] wad_http_req_convert_tfap_tunnel :3256 Wait for tunnel convertion.
[I][p:3335][s:41045] wad_vs_http_server_connected :5487 vs http server connected
That is more or less everything relevant that the FortiGate does in a basic ZTNA TCP forwarding connection.
At a high level, it’s:
Certificate validation
An optional step for DNS resolution
Policy matching
Establishing a proxy connection
Now, let’s turn to some options we can enable to cover some other use cases.
UDP? UDP.
Since FortiOS 7.6.0 and FortiClient 7.4.1, UDP connections can also be handled by ZTNA; before that, only TCP was supported.
The way this works is that when FortiClient recognizes a relevant UDP connection attempt, the UDP traffic is encapsulated in TCP, a QUIC connection is formed, and traffic gets forwarded like a regular ZTNA connection by FortiTCS.
Here is a debug log from a ZTNA UDP connection from fortitcs_X.log
The log doesn’t look that different from a TCP connection, except that we can see that “proto=udp” is now in the HTTP request, but HTTP 1.1 is still in use here, so no QUIC now.
The debug log on the FortiGate’s side is a bit more interesting:
The first line already tells us about the QUIC connection that is being started. We then get the regular virtual host matching and certificate verification.
The HTTP GET now shows “HTTP/3.0”, so we know this is QUIC, and it also has an “accept-encoding” header with gzip as its value, but I can’t tell you for what gzip is used.
The rest of the debug is much the same as before, except with UDP traffic.
About that Encryption setting
We can also enable the encryption setting for ZTNA destinations, and the documentation states the following:
When Encryption is enabled, traffic between FortiClient and the FortiGate is always encrypted, even if the original traffic has already been encrypted. When Encryption is disabled, traffic between FortiClient and the FortiGate is not encrypted.
So this setting makes sense for traffic that isn’t encrypted at a protocol level, like FTP, HTTP, telnet, etc.
How does this look in practice? Here is a comparison picture of two Wireshark captures of HTTP requests, performed on the client, to win-server.ad.labdomain.com to get the default Microsoft IIS start page. The encrypted connection is on the left, and the unencrypted connection is on the right.
We can see that only destination port 443 is ever in use, because traffic is destined to the FortiGate, which has its access proxy listening on 443, but the traffic being sent is the actual HTTP traffic, which is why we see “iisstart.png” and “favicon.ico” in the unencrypted version, but we don’t in the encrypted version.
It’s a bit pointless to show, but you can believe me that the end result for the client was the same page.
So if you turn on the encryption setting, even the client’s NIC doesn’t know what is being transported, regardless of the protocol.
A quick look at the fortitcs_X.log doesn’t display many differences, except that the “tls” parameter is now at 1.
The only notable difference on the FortiGate between an encrypted and an unencrypted connections that I could find, is this on the unencrypted connection:
[I][p:693] wad_http_req_set_tfap_param :3193 req:0x7f9492c048 got tunnel param type:http proto:tcp off_ssl:1
[I][p:693][s:5086][r:33554433] wad_dump_fwd_http_resp :3087 hreq=0x7f9492c048 Forward response from Internal:
HTTP/1.1 101 Switching Protocols
Upgrade: tcp-forwarding/1.0
Connection: Upgrade
[I][p:693][s:5086][r:33554433] __wad_http_req_close :2053 ret = -1!
[I][p:693][s:5086][r:33554433] wad_http_clt_turn_off_ssl :3697 replace SSL transport.
[I][p:693][s:5086][r:33554433] wad_http_strm_turn_ssl_off :3324 waiting for SSL port(0x7f939096a0) to close.
[I][p:693][s:5086][r:33554433] wad_http_clt_turn_off_ssl :3726 wait for SSL port.
[I][p:693][s:5086][r:33554433] wad_http_sstrm_off_ssl_for_tunnel :3647 Server is ready to drain SSL data.
[I][p:693][s:5086][r:33554433] wad_http_req_convert_tfap_tunnel :3256 Wait for tunnel convertion.
[I][p:693][s:5086] wad_vs_http_server_connected :5487 vs http server connected
[I][p:693] wad_http_strm_on_clt_ssl_sock_port:3572 client port replaced.
[I][p:693] wad_http_cstrm_off_ssl_for_tunnel :3542 Client is ready to drain SSL data.
[I][p:693] wad_http_strm_on_clt_ssl_sock_port:3575 Client off-ssl done.
The encrypted connection has “off_ssl” set to 0 instead of 1, and the SSL transport replacement, closing, and draining aren’t present on the encrypted connection either. SSL here is, of course, TLS, but we all know how good the industry is with displacing the term SSL.
Here is the encrypted connection as a comparison:
[I][p:725] wad_http_req_set_tfap_param :3193 req:0x7f96101048 got tunnel param type:http proto:tcp off_ssl:0
[I][p:725][s:5673][r:33554433] wad_dump_fwd_http_resp :3087 hreq=0x7f96101048 Forward response from Internal:
HTTP/1.1 101 Switching Protocols
Upgrade: tcp-forwarding/1.0
Connection: Upgrade
[I][p:725][s:5673][r:33554433] __wad_http_req_close :2053 ret = -1!
[I][p:725][s:5673][r:33554433] wad_http_strm_convert_tunnel :4391 cstrm(0x7f948deb00) sstrm(0x7f948dec88) type:http proto:tcp
[I][p:725][s:5673][r:33554433] wad_http_srv_slct_static_set_connectable:1113 static server selector connectable set to 0.
ap posture change observer allocated, 0x7f945e7ce8
[I][p:725][s:5673][r:33554433] wad_http_req_convert_tfap_tunnel :3256 Wait for tunnel convertion.
[I][p:725][s:5673] wad_vs_http_server_connected :5487 vs http server connected
Do we need EMS for this?
Yes. Moving on.
…
…
…
Okay, let’s expand on this. We absolutely need EMS for a ZTNA TCP forwarding connection, because only with licensed FortiClients, which require EMS, can we even configure ZTNA destinations.
EMS gives every user a client certificate, and this certificate is, by default, verified by the FortiGate. In debugs further up, we already saw this, but here it is again:
[I][p:3335][s:41045] wad_vs_ssl_port_caps_cert_req :14077 675:ZTNA-LAB: requested certs from client. ca_store is ready.
[I][p:3335][s:41045] wad_vs_ssl_c2p_cert_req_add_trust_issuers:14038 675:ZTNA-LAB: adding subject: C = CA, ST = California, L = Sunnyvale, O = Fortinet, OU = 00000000000000000000000000000000, CN = FCTEMS8824011503 to CCR.
[I][p:3335][s:41045] wad_vs_ssl_c2p_cert_req_add_trust_issuers:14032 675:ZTNA-LAB: cas->x509 is (nil) or ca list is empty.
[I][p:3335][s:41045] wad_vs_ssl_access_proxy_on_clt_certs:13476 675:ZTNA-LAB: received certs from the client.
[I][p:3335][s:41045] __wad_ssl_cert_open_cert :641 https server uses key_len 2048
[I][p:3335][s:41045] wad_ssl_cert_check_auth_status_with_ca_store:464 authi(0x7fa1ec2570) status(0)
[I][p:3335][s:41045] wad_ui_ssl_cert_verify :1562 Certificate verified!
[I][p:3335][s:41045] wad_ssl_cert_parse_cert :1448 EMS SN=FCTEMS8824011503
[I][p:3335][s:41045] wad_ssl_cert_parse_cert :1453 Tenant ID=00000000000000000000000000000000
[I][p:3335][s:41045] wad_ssl_cert_get_sn :1364 ci(0x7fa1b10eb8): sn=3AA3BF4801BF5771BF3079CCDFB7F66DF0841E6F
[I][p:3335][s:41045] __wad_ssl_validate_ztna_cert_by_ca_store:3465 Successfully verified cert by CA.
[I][p:3335][s:41045] wad_vs_ssl_access_proxy_on_clt_certs:13530 675:ZTNA-LAB: Cert auth success. issued_by: ems
When a FortiGate connects to EMS, it will load the FortiClient ID, the EMS serial that hands out user certificates, endpoint information, associated hardware information, and the EMS ZTNA root CA certificate into memory. Note that nothing about the user certificate is mentioned in these messages, because this is endpoint information, not user information.
FortiGate endpoint update
wad_ui_global_ec_fctems_on_event :5299 receive global endpoint-control fctems event.
[I][p:604] wad_conf_update_gen_array :6704 vd: NA update: [global]endpoint-control.fctems now_gen: 1834171729 last_gen:1834171728
accepted connection from pid 192
accept us=0x7f8c90fa90 fd=32 make_ret=1
[I][p:597] wad_info_store_update_ec :144 Update EC(uid F3AB0220E981419CAA9A13CE4811E3F9 sn FCTEMS8824011503 tenant id 00000000000000000000000000000000).
Received multi-src attr src=EMS, type=VENDOR, val=Dell Inc., weight=255
Received multi-src attr src=EMS, type=HW_TYPE, val=Latitude 5300, weight=255
Hardware/Software src infos before updating
Merged node result:
Last updated time:2025-12-01 19:28:16 src=CID, type=VENDOR, val=Dell, weight=128
Last updated time:2025-12-01 19:28:16 src=CID, type=OS_NAME, val=Windows, weight=128
Source node 1, src=CID
Last updated time:2025-12-01 19:28:16 src=CID, type=VENDOR, val=Dell, weight=128
Last updated time:2025-12-01 19:28:16 src=CID, type=OS_NAME, val=Windows, weight=128
Hardware/Software src infos after updating
Merged node result:
Last updated time:2025-12-01 19:30:03 src=EMS, type=VENDOR, val=Dell Inc., weight=255
Last updated time:2025-12-01 19:30:03 src=EMS, type=HW_TYPE, val=Latitude 5300, weight=255
Last updated time:2025-12-01 19:28:16 src=CID, type=OS_NAME, val=Windows, weight=128
Source node 1, src=EMS
Last updated time:2025-12-01 19:30:03 src=EMS, type=VENDOR, val=Dell Inc., weight=255
Last updated time:2025-12-01 19:30:03 src=EMS, type=HW_TYPE, val=Latitude 5300, weight=255
Source node 2, src=CID
Last updated time:2025-12-01 19:28:16 src=CID, type=VENDOR, val=Dell, weight=128
Last updated time:2025-12-01 19:28:16 src=CID, type=OS_NAME, val=Windows, weight=128
Updating attr to interface src=EMS, type=VENDOR, val=Dell Inc., weight=255
Updating attr to interface src=EMS, type=VERSION, val=, weight=0
Updating attr to interface src=EMS, type=HW_TYPE, val=Latitude 5300, weight=255
Updating attr to interface src=EMS, type=HW_FAMILY, val=, weight=0
Updating attr to interface src=CID, type=OS_NAME, val=Windows, weight=128
Updating attr to interface src=EMS, type=OS_VERSION, val=, weight=0
[I][p:604] wad_ui_ems_ca_cert_checker :5787 checker 'ems-ca-cert': now_gen: 3 last_gen:2
[I][p:604] wad_update_conf_checker :6737 checker ems-ca-cert update
[I][p:523] wad_ui_ssl_ems_ca_changed :858 cur_gen=0, new_gen=1
[I][p:523] wad_ui_ssl_ca_store_flush :3208 Flush ca_store!
[I][p:523] __wad_ui_ssl_ca_store_load :3546 Flush the ca store in VDOM root!
[I][p:523] __wad_ui_ssl_ca_store_load :3568 Load CA file /var/log/ems_ztna_certs/global/EMS1-ZTNARootCA.pem!
[I][p:523] __wad_ui_ssl_ca_store_load :3576 Load CA files!
[I][p:523] wad_ui_ssl_ca_store_load :3629 Load CA path /var/log/ems_ztna_certs/global, vdom = root, ret = 1.
[I][p:523] wad_vs_servers_activate_standby :2316 no standby server to activate.
[I][p:523] wad_vs_gwy_get_servers_nop :3527 684:ZTNA-LAB:4294967295: trace
The verification has a caching mechanism, so EMS doesn’t have to be online 100% of the time.
[I][p:273][s:1490] wad_vs_ssl_port_caps_cert_req :14077 684:ZTNA-LAB: requested certs from client. ca_store is ready.
[I][p:273][s:1490] wad_vs_ssl_c2p_cert_req_add_trust_issuers:14038 684:ZTNA-LAB: adding subject: C = CA, ST = California, L = Sunnyvale, O = Fortinet, OU = 00000000000000000000000000000000, CN = FCTEMS8824011503 to CCR.
[I][p:273][s:1490] wad_vs_ssl_c2p_cert_req_add_trust_issuers:14032 684:ZTNA-LAB: cas->x509 is (nil) or ca list is empty.
[I][p:273][s:1490] wad_vs_ssl_access_proxy_on_clt_certs:13476 684:ZTNA-LAB: received certs from the client.
[I][p:273][s:1490] wad_ssl_cert_auth_find :176 Found auth_info (stable)
[I][p:273][s:1490] wad_vs_ssl_access_proxy_on_clt_certs:13530 684:ZTNA-LAB: Cert auth success. issued_by: ems
“Found auth info (stable)” is the short version of the full verification using cached information, from what I can gather.
If the FortiGate reboots, the cached information gets lost, and if EMS isn’t available, the EMS ZTNA root CA certificate won’t be loaded, and thus the certificate can’t be verified.
FortiGate certificate validation failure
[I][p:273] wad_unix_stream_sendmsg :297 WAD unix stream stream 0x7f75aa7048 msg=0x7f75a8f048 n_fd=1.
[I][p:276] wad_tcp_port_alloc :1482 alloc tcp_port=0x7fa5058568
[I][p:276] __wad_tcp_port_rebuild :2517 rebuild client TCP 172.31.1.2:61586 -> 172.31.1.1:443 on 62
[I][p:276][s:488] wad_session_context_learn_session_config:464 vf_id=0 ses_ctx=0x7fa531b0e8 policy-id=0, sec_profile=(nil) app_type=http
wan_opt_mode=0 av_idx=0 dd_method=0 wan_opt_tcp=0
tp-mode=0 web_cache=0 webcache_ssl=0
check_policy: http=0 ssh=0 ssh_tun=0 fw_ztna=0 ap=1
ipsapp_redirect=0
ssl_enabled=0 ssl_full=0 wanopt_ssl=0 ssl_proc=
ses_ctx:t|Pv|M|H|C|A7|O fwdsvr=''
[I][p:276][s:488] wad_ssl_port__open :23548 port=0x7fa5058568 type=10 making SSL port
[I][p:276][s:488] wad_ssl_port_inline_ips_init :2665 wsp=0x7fa5051028/10 inline-ips disabled
[I][p:276][s:488] wad_ssl_port__open :23864 wsp=0x7fa5051028/10 SSL-port open succ type=10 port=0x7fa5058568 vd=0 svr=172.31.1.1:443: succ
[I][p:276][s:488] wad_ssl_port_run :23919 sp=0x7fa5051028/10 state=1
[I][p:276] wad_unix_stream_flush_data :650 WAD unix stream stream 0x7fa5c1f048 write (1,4)
[I][p:276][s:488] wad_tcp_port_on_event :1903 start processing tcp event=0x1 events=0x1 fd=62 n_out_block=0 state=2 close/shut=0/0 n_out_block=0
[I][p:276][s:488] wad_tcp_port_on_read :1779 sock 62 read (0,4072)
[I][p:276][s:488] wad_ssl_sock_port_exec_up_forward_txn:18919 sp=0x7fa5051028/10 forwarded len=193/193
[I][p:276][s:488] wad_vs_ssl_port_caps_c2p_on_client_hello:11217 684:ZTNA-LAB: wsp(0x7fa5051028) handshake recv ClientHello record 3.1 client 3.3 supported 3.3
[I][p:276][s:488] wad_elliptic_curve :3354 found elliptic curve 25
[I][p:276][s:488] wad_vs_ssl_port_client_hello_full :10618 wsp 0x7fa5051028 ssl full handshake
[I][p:276][s:488] wad_vs_ssl_port_caps_c2p_on_server_hello:12629 wsp 0x7fa5051028 handshake sent ServerHello 3.3
[I][p:276][s:488] wad_vs_ssl_port_caps_cert_req :14077 684:ZTNA-LAB: requested certs from client. ca_store is empty.
[I][p:276][s:488] wad_vs_ssl_c2p_cert_req_add_trust_issuers:14032 684:ZTNA-LAB: cas->x509 is (nil) or ca list is empty.
[I][p:276][s:488] wad_vs_ssl_c2p_cert_req_add_trust_issuers:14032 684:ZTNA-LAB: cas->x509 is (nil) or ca list is empty.
[I][p:276][s:488] wad_vs_ssl_access_proxy_on_clt_certs:13476 684:ZTNA-LAB: received certs from the client.
[I][p:276][s:488] __wad_ssl_cert_open_cert :641 https server uses key_len 2048
[I][p:276][s:488] wad_vs_ssl_access_proxy_on_clt_certs:13508 684:ZTNA-LAB: Empty EMS CAs!
[W][p:276][s:488] wad_ssl_clt_cert_chk_user_ca :13390 vd=root not configured user ca
[W][p:276][s:488] wad_vs_ssl_access_proxy_on_clt_certs:13542 684:ZTNA-LAB: Cert auth failed. status=9
[I][p:276][s:488] wad_ssl_cert_parse_cert :1448 EMS SN=FCTEMS8824011503
[I][p:276][s:488] wad_ssl_cert_parse_cert :1453 Tenant ID=00000000000000000000000000000000
[I][p:276][s:488] wad_ssl_cert_get_sn :1364 ci(0x7fa50f5f40): sn=03F6813097089958ECC2496BBBDD1917E0A09131
[I][p:276][s:488] wad_vs_log_clt_cert_failure :98 684:ZTNA-LAB: Traffic denied because cert auth failed, cert-cn:F3AB0220E981419CAA9A13CE4811E3F9, cert-issuer:FCTEMS8824011503, cert-status:untrusted fail-reason:(null)
[I][p:276] wad_vs_ssl_port_caps_c2p_on_handshake_done:12075 wsp 0x7fa5051028 type 10 handshake done
[I][p:276] wad_vs_http_make :5500 make vs http session
[I][p:276] wad_http_srv_selector_multiplex_make:2026 make multiplex server selector.
[I][p:276] wad_http_full_ses_make :15795 make ok session=0x7fa8219c80 server=(nil).
[I][p:276] wad_vs_ssl_port_c2p_issue_ticket :11983 684:ZTNA-LAB: wsp(0x7fa5051028) can't offer ticket to client.
[I][p:276][s:488][r:33554433] wad_dump_http_request :3066 hreq=0x7fa6c40048 Received request from client: 172.31.1.2:61586
GET /tcp?address=win-server.ad.labdomain.com&port=3389&tls=0 HTTP/1.1
Host: 172.31.1.1
User-Agent: Forticlient
Accept: /
Connection: Upgrade
Cookie:
Upgrade: tcp-forwarding/1.0
X-Fos-Auth:
[I][p:276][s:488][r:33554433] wad_http_parse_host :1941 host=[10]172.31.1.1
[I][p:276][s:488][r:33554433] wad_http_str_canonicalize :2466 enc=0 path=/tcp len=4 changes=0
[I][p:276][s:488][r:33554433] wad_http_str_canonicalize :2468 end=4 path=address=win-server.ad.labdomain.com&port=3389&tls=0 len=51 changes=0
[I][p:276][s:488][r:33554433] wad_http_req_detect_special :14378 captive_portal detected: false, preflight=(null)
[I][p:276][s:488][r:33554433] __wad_http_build_replmsg_resp :951 Generating replacement message. repmsg_id 88
[I][p:276][s:488][r:33554433] wad_dump_fwd_http_resp :3087 hreq=0x7fa6c40048 Forward response from Internal:
HTTP/1.1 403 Forbidden
Connection: close
Content-Type: text/html
Cache-Control: no-cache
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
Content-Security-Policy: frame-ancestors 'self'
Content-Length: 35174
In response to the 403, FortiClient displays a message on its internal browser that says the certificate is invalid, which isn’t strictly true, since in this case, it can’t be verified.
This HTML is built by FortiTCS, along with some other error messages.
If a certificate gets revoked via EMS, three things happen:
After a sync, FortiClient deletes the certificate from the user store on the endpoint.
The FortiGate gets an update of the endpoint, but the certificate isn’t mentioned in it (this is the same update that was shown further above).
The certificate gets re-enrolled on the endpoint after a few syncs, so this isn’t the end of everything.
But what if the endpoint doesn’t have a connection to EMS, so it never gets the message to delete the certificate? This leads to a situation where a technically revoked certificate gets used to access a service that requires a valid certificate, so what does the FortiGate say here?
Well, EMS does not provide a Certificate Revocation List (CRL), and without that, the FortiGate verifies the certificate against the EMS ZTNA root CA certificate, and since there can’t be a problem there, the ZTNA connection will work. This means that a revoked certificate does not mean that a client can’t access ZTNA resources.
This behaviour sounds like a New Feature Request (NFR) to me. Maybe we will get a CRL in the future.
[Update 2026-08-21]: After some digging in the EMS filesystem, I did find a CRL, and you can download it using the API endpoint /api/v1/ztna_certificates/crl/download/base, e.g https://ip-fqdn/api/v1/ztna_certificates/crl/download/base, but this CRL does not get populated if you revoke a certificate on EMS. Maybe this will happen at some point, and I will update this post if that happens. At the time of this update, the CRL is useless. There is also an /api/v1/ztna_certificates/crl/download/delta endpoint, but that only gives a {"messages":"Serial number (null) does not exist. Please establish a fabric connector with EMS.","retval":-4} message.
Wrapping up
After all this, there is still a lot I left on the floor, because this is just TCP forwarding. I did not go into ZTNA tags, HTTP(S) access proxies or SaaS forwarding, for example. I will leave these things for another day, because this article is already long enough. I wanted to start with TCP forwarding, because it’s the most important part in Fortinet’s ZTNA strategy, and I think it’s the best way to start with understanding the other parts.
I hope that this entire thing was interesting and hopefully helpful to you, dear reader.