Tag: fortigate

  • FortiOS 8.0 new features review

    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.

    Custom tags for objects and policies

    Custom tags for addresses, policies, and dynamic tag address groups | FortiGate / FortiOS 8.0.0 | Fortinet Document Library

    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.

    FortiOS 8.0: Dynamic Tag Based Address Group

    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.

    Fabric Feeds

    Cloud-based Fabric Feed synchronization | FortiGate / FortiOS 8.0.0 | Fortinet Document Library

    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.

    FortiAnalyzer indicators

    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.

    FortiGate Fabric Feeds

    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.

    Blocking indicators | FortiAnalyzer 7.6.6 | Fortinet Document Library

    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.

    TLS 1.3 based VPN over TCP

    TLS 1.3 based VPN over TCP | FortiGate / FortiOS 8.0.0 | Fortinet Document Library

    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.

    High Availability actions in the GUI

    GUI support for HA actions, health status, and config diff | FortiGate / FortiOS 8.0.0 | Fortinet Document Library

    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.

    DHCP templates

    New features or enhancements | FortiGate / FortiOS 8.0.0 | Fortinet Document Library (Feature ID 1130044)

    config system dhcp template | FortiGate / FortiOS 8.0.0 | Fortinet Document Library 

    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.

    DHCP template GUI

    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.

    DHCP template configuration

    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

    Control SD-WAN interface usage based on monthly traffic volume (quota) | FortiGate / FortiOS 8.0.0 | Fortinet Document Library

    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.

    Changes to ZTNA configuration

    ZTNA configuration simplification | FortiGate / FortiOS 8.0.0 | Fortinet Document Library 

    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.

    ZTNA 8.0 overview

    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.

    FortiGate 8.0 ZTNA configuration – All methods + Entra SAML SSO – KNS | ˈkɛvɪn netsec

    Post-quantum cryptography

    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

    New features or enhancements | FortiGate / FortiOS 8.0.0 | Fortinet Document Library (Feature ID 1202253)

    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.

    FortiGate GUI PQC

    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

    Proxy-mode deep inspection supports hybrid PQC

    Support for hybrid PQC SSL deep inspection in proxy mode | FortiGate / FortiOS 8.0.0 | Fortinet Document Library

    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.

    Agentless VPN supports PQC

    New features or enhancements | FortiGate / FortiOS 8.0.0 | Fortinet Document Library (Feature ID 1152420)

    Agentless VPN, or SSL-VPN web mode for the old-timers, can now use post-quantum and hybrid post-quantum key exchange groups to secure client access.

    FGT-80-REVIEW(Primary) # config vpn ssl settings 
    
    FGT-80-REVIEW(Primary) (settings) # set tls-groups 
    P-521              P-521
    P-384              P-384
    P-256              P-256
    ML-KEM512          ML-KEM512
    ML-KEM768          ML-KEM768
    ML-KEM1024         ML-KEM1024
    P-384-MLKEM1024    P-384-MLKEM1024
    P-256-MLKEM768     P-256-MLKEM768
    X25519-MLKEM768    X25519-MLKEM768
    X448               X448
    X25519             X25519
    FFDHE2048          FFDHE2048
    FFDHE3072          FFDHE3072
    FFDHE4096          FFDHE4096
    FFDHE6144          FFDHE6144
    FFDHE8192          FFDHE8192

    Artificial Intelligence

    We all knew it would come, but Artificial Intelligence (AI) is now in FortiOS in earnest. Previously, AI-anything, in FortiOS, was used for things like anti-virus for malware scanning and recognizing and controlling AI-usage using application control, but now we have so much more, beginning with…

    FortiAI-Assist

    FortiAI assistant and CLI Code Lab | FortiGate / FortiOS 8.0.0 | Fortinet Document Library

    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).

    FortiAI SNAT problem

    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.

    FortiAI thinking

    Let’s give it the debug output and check again.

    FortiAI SNAT debug result

    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.

    FortiAI SNAT policy problem

    As another example, let’s see how it handles local-in policy creation.

    FortiAI local-in prompt
    FortiAI local-in result

    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.

    CLI Code Lab

    FortiAI assistant and CLI Code Lab | FortiGate / FortiOS 8.0.0 | Fortinet Document Library

    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.

    CLI Code Lab transaction start
    CLI Code Lab failure

    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.

    CLI Code Lab transaction
    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

    New features or enhancements | FortiGate / FortiOS 8.0.0 | Fortinet Document Library (Feature ID 1157379)

    DNS is an often-used candidate for data exfiltration and command-and-control traffic, so this new feature, if it works well, is an easy win.

    This feature also comes with a whitelist of some domains that are exempted from this type of scanning, which I’m not a fan of.

    There doesn’t seem to be any information on how it works, or if you can configure it in any way, so we just have to rely on it working correctly.

    MCP and A2A detection support

    Agentic AI protocol support in FortiOS | FortiGate / FortiOS 8.0.0 | Fortinet Document Library 

    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

    Support trusted host settings for managed switches | FortiGate / FortiOS 8.0.0 | Fortinet Document Library

    Provide private data encryption for managed switches | FortiGate / FortiOS 8.0.0 | Fortinet Document Library

    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.

    HA role is visible in CLI prompts

    HA role shown in CLI prompt | FortiGate / FortiOS 8.0.0 | Fortinet Document Library

    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.

    Log summary panel

    Summary panel in Log Details | FortiGate / FortiOS 8.0.0 | Fortinet Document Library

    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.

    Disallowed login methods per administrator

    Administrator access hardening | FortiGate / FortiOS 8.0.0 | Fortinet Document Library 

    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.

    FortiAP IPsec traffic offloading

    Support IPsec traffic offloading from the FortiAP | FortiGate / FortiOS 8.0.0 | Fortinet Document Library 

    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

    Send security posture tags from FortiClient to FortiOS directly as JWT | FortiGate / FortiOS 8.0.0 | Fortinet Document Library 

    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.

    Application sanctioning

    New classification framework for application signatures and FortiView support | FortiGate / FortiOS 8.0.0 | Fortinet Document Library 

    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.

    The negative one: Changes to revisions

    Configuration revisions and logout backup default changes | FortiGate / FortiOS 8.0.0 | Fortinet Document Library 

    It’s not all good in FortiOS 8.0. The changes to revisions on FortiGates are simply bad.

    You can no longer use revisions in any way if your FortiGate does not have a hard disk.

    I assume this was done for performance reasons, but it still sucks to see it.

    The most important one: Custom GUI themes

    Custom GUI themes and admin‑level personalization | FortiGate / FortiOS 8.0.0 | Fortinet Document Library 

    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.

    Hot pink
    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.

  • End-to-end FortiGate ZTP with FortiManager for SD-WAN and ADVPN

    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:

    1. A factory default branch FortiGate gets an IP, and thus a WAN connection, via DHCP
    2. The branch FortiGate contacts FortiZTP for partial provisioning
    3. A VPN tunnel, as well as a BGP neighborship, gets established between the branch and the hub
    4. The branch FortiGate connects to FortiManager via the VPN tunnel
    5. FortiManager fully provisions the FortiGate
    Diagram of ZTP flow

    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:

    1. Device groups
    2. Metadata variables
    3. Policy packages
    4. 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:

    1. Hubs
    2. Branches
    3. Branches with single WAN
    4. Branches with dual WAN
    Created device groups

    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 Icon to show metadata variable possibility, 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.

    1. Branch IPsec SD-WAN zone to the BGP loopback to allow BGP traffic
    2. Branch IPsec SD-WAN zone to the health check loopback to allow ping traffic for our SLA
    3. 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:

    Policy of the branches
    Policy of the hub

    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).

    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:

    {# PROJECT VARIABLES #}
    {# INTERFACES #}
    {% set interfaces = {
      'vlan_interfaces' :[
        { "id": 100, "alias": "TEST100", "suboctet": 100, "dhcp_relay": true},
        { "id": 101, "alias": "TEST101", "suboctet": 101, "dhcp_relay": false}
      ]
      }
    %}
    {# 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.

    IPsec installation targets

    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:

    1. 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.
    2. 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.

    SD-WAN template

    In the SD-WAN template, we configure ADVPN 2.0, without transport groups, and use embedded SLA information in ICMP probes.

    There are two SD-WAN zones:

    1. SDWAN-HUBS, with both overlays
    2. SDWAN-OUTSIDE, with both WAN ports

    Both these zones have both members defined, but the installation target narrows down who gets what.

    SD-WAN member installation targets

    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.

    SD-WAN rules on FortiManager and FortiGate

    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)
    IPsec mode-cfg and IP on the hub
    • SD-WAN: Uses a remote performance SLA with priority IN-SLA and priority OUT-SLA values to dynamically steer branch traffic.
    Remote SLA on the hub

    Now that we have all our templates, we can create the template groups and assign them to our groups.

    Template 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.

    ZTP assets

    If a FortiGate starts from a factory default/reset state, it will:

    1. 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.
    2. Try to connect to FortiCloud and look if it is provisioned for ZTP
    3. Connect to a selected target:
      1. FortiGate Cloud
      2. FortiManager Cloud
      3. An on-premise FortiManager
    4. 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.

    FortiGate config touched

    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:

    1. The loopback interface for BGP
    2. An IPsec configuration
    3. A BGP configuration
    4. An SD-WAN configuration for our member and zone
    5. A policy that uses the SD-WAN zone so that the tunnel establishes
    6. 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.

    FortiManager in FortiZTP

    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.

    ZTP settings for FortiGate

    Now our FortiGate is ready to be provisioned by ZTP.

    ZTP provisioning waiting status

    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-link enabled status
    Auto-link running status

    Auto-linking creates two tasks on FortiManager:

    1. Push config to device: This signalizes the pushing of the entire configuration to the FortiGate
    2. Install Package ‘<PACKAGE-NAME’>: Installs the policy package
    Auto-link success

    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.

    Failure of auto-linking in Task Monitor

    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.

    1. Provision the FortiGate on FortiZTP
    2. Create a model device on FortiManager using the CSV
    3. Do a Quick Install on the model device
      1. 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
      1. 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
      2. The FortiGate will try to contact FortiZTP to check for any provisioning
      3. The pre-run CLI script will be downloaded and run
      4. The FortiGate will connect to FortiManager
      5. FortiManager will auto-link the FortiGate to the model device
      6. If enforced in the blueprint, the firmware of the FortiGate will be upgraded
      7. If the firmware was upgraded, signatures and databases like the ISDB, IPS, and application control might get upgraded as well
      8. The full configuration, including the policy package, gets pushed to the FortiGate
      9. On FortiManager, the config status becomes Synchronized, the policy package status gets a green check mark, and on FortiZTP, we see Provisioned
      10. ZTP is done
      Update of signatures during auto-link
      Auto-link success
      ZTP provisioned

      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.

      Importing multiple HA clusters

      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.

      HA model device hostname issue

      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_id is 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.

    4. FortiAnalyzer playbook variables: How to help yourself

      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:

      1. Get User List, which fetches a type of user (LDAP, RADIUS, or local) and saves it in a list
      2. Update User Status, which we will use to deactivate a user using an ID

      I have prepared the following playbook:

      FortiAnalyzer FortiAuthenticator playbook

      There are four tasks in this:

      1. An On Demand trigger
      2. The Get User List task, using the FAC connector, to get the user list
      3. The Update User Status task, using the FAC connector, to deactivate a user by ID
      4. 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:

      FortiAnalyzer generic webhook

      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:

      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.

      FortiAnalyzer playbook execution

      Ubuntu POST echo

      [server] event: listening (port: 8081)
      [server] event: connection (socket#1)
      [socket#1] event: resume
      [socket#1] event: data
      --> POST / HTTP/1.1
      --> Host: 192.168.1.151:8081
      --> Accept-Encoding: identity
      --> User-Agent: python-urllib3/2.3.0
      --> Content-Length: 1164
      -->
      --> {"meta": {"limit": 10, "next": null, "offset": 0, "previous": null, "total_count": 2}, "objects": [{"active": true, "company": null, "department": null, "dn": "CN=manager,OU=USERS,OU=LAB,DC=ad,DC=labdomain,DC=com", "email": "", "fido": false, "first_name": "manager", "ftm_act_method": "", "id": 2, "is_locked": false, "last_name": "", "mobile_number": "", "reason": null, "recovery_by_question": false, "resource_uri": "/api/v1/ldapusers/2/", "server_address": "win-ad.ad.labdomain.com", "server_name": "WIN-AD", "token_auth": false, "token_serial": "", "token_type": "", "username": "manager@ad.labdomain.com"}, {"active": false, "company": "LAB", "department": "NetSec", "dn": "CN=adkevin,OU=USERS,OU=LAB,DC=ad,DC=labdomain,DC=com", "email": "adkevin@ad.labdomain.com", "fido": false, "first_name": "adkevin", "ftm_act_method": "email", "id": 1, "is_locked": false, "last_name": "LAB", "mobile_number": "", "reason": 0, "recovery_by_question": false, "resource_uri": "/api/v1/ldapusers/1/", "server_address": "win-ad.ad.labdomain.com", "server_name": "WIN-AD", "token_auth": false, "token_serial": "", "token_type": "", "username": "adkevin@ad.labdomain.com"}]}
      [socket#1] event: prefinish
      [socket#1] event: finish
      [socket#1] event: readable
      [socket#1] event: end
      [socket#1] event: close

      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.

      JSON formatter view

      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.

      FortiAnalyzer sniffer output

      2026-03-23 18:53:44.784960 192.168.1.151.8081 -> 192.168.1.171.57014: psh 154846471 ack 44744771
      0x0000   0000 0000 0001 000c 2931 3adc 0800 4500        ........)1:...E.
      0x0010   05c9 398d 4000 4006 770f c0a8 0197 c0a8        ..9.@.@.w.......
      0x0020   01ab 1f91 deb6 093a c507 02aa c043 5018        .......:.....CP.
      0x0030   0209 ff1a 0000 4461 7465 3a20 4d6f 6e20        ......Date:.Mon.
      0x0040   4d61 7220 3233 2032 3032 3620 3138 3a35        Mar.23.2026.18:5
      0x0050   333a 3434 2047 4d54 2b30 3030 3020 2843        3:44.GMT+0000.(C
      0x0060   6f6f 7264 696e 6174 6564 2055 6e69 7665        oordinated.Unive
      0x0070   7273 616c 2054 696d 6529 0d0a 436f 6e6e        rsal.Time)..Conn
      0x0080   6563 7469 6f6e 3a20 636c 6f73 650d 0a43        ection:.close..C
      0x0090   6f6e 7465 6e74 2d54 7970 653a 2074 6578        ontent-Type:.tex
      0x00a0   742f 706c 6169 6e0d 0a41 6363 6573 732d        t/plain..Access-
      0x00b0   436f 6e74 726f 6c2d 416c 6c6f 772d 4f72        Control-Allow-Or
      0x00c0   6967 696e 3a20 2a0d 0a0d 0a50 4f53 5420        igin:.*....POST.
      0x00d0   2f20 4854 5450 2f31 2e31 0d0a 486f 7374        /.HTTP/1.1..Host
      0x00e0   3a20 3139 322e 3136 382e 312e 3135 313a        :.192.168.1.151:
      0x00f0   3830 3831 0d0a 4163 6365 7074 2d45 6e63        8081..Accept-Enc
      0x0100   6f64 696e 673a 2069 6465 6e74 6974 790d        oding:.identity.
      0x0110   0a55 7365 722d 4167 656e 743a 2070 7974        .User-Agent:.pyt
      0x0120   686f 6e2d 7572 6c6c 6962 332f 322e 332e        hon-urllib3/2.3.
      0x0130   300d 0a43 6f6e 7465 6e74 2d4c 656e 6774        0..Content-Lengt
      0x0140   683a 2031 3136 340d 0a0d 0a7b 226d 6574        h:.1164....{"met
      0x0150   6122 3a20 7b22 6c69 6d69 7422 3a20 3130        a":.{"limit":.10
      0x0160   2c20 226e 6578 7422 3a20 6e75 6c6c 2c20        ,."next":.null,.
      0x0170   226f 6666 7365 7422 3a20 302c 2022 7072        "offset":.0,."pr
      0x0180   6576 696f 7573 223a 206e 756c 6c2c 2022        evious":.null,."
      0x0190   746f 7461 6c5f 636f 756e 7422 3a20 327d        total_count":.2}
      0x01a0   2c20 226f 626a 6563 7473 223a 205b 7b22        ,."objects":.[{"
      0x01b0   6163 7469 7665 223a 2074 7275 652c 2022        active":.true,."
      0x01c0   636f 6d70 616e 7922 3a20 6e75 6c6c 2c20        company":.null,.
      0x01d0   2264 6570 6172 746d 656e 7422 3a20 6e75        "department":.nu
      0x01e0   6c6c 2c20 2264 6e22 3a20 2243 4e3d 6d61        ll,."dn":."CN=ma
      0x01f0   6e61 6765 722c 4f55 3d55 5345 5253 2c4f        nager,OU=USERS,O
      0x0200   553d 4c41 422c 4443 3d61 642c 4443 3d6c        U=LAB,DC=ad,DC=l
      0x0210   6162 646f 6d61 696e 2c44 433d 636f 6d22        abdomain,DC=com"
      0x0220   2c20 2265 6d61 696c 223a 2022 222c 2022        ,."email":."",."
      0x0230   6669 646f 223a 2066 616c 7365 2c20 2266        fido":.false,."f
      0x0240   6972 7374 5f6e 616d 6522 3a20 226d 616e        irst_name":."man
      0x0250   6167 6572 222c 2022 6674 6d5f 6163 745f        ager",."ftm_act_
      0x0260   6d65 7468 6f64 223a 2022 222c 2022 6964        method":."",."id
      0x0270   223a 2032 2c20 2269 735f 6c6f 636b 6564        ":.2,."is_locked
      0x0280   223a 2066 616c 7365 2c20 226c 6173 745f        ":.false,."last_
      0x0290   6e61 6d65 223a 2022 222c 2022 6d6f 6269        name":."",."mobi
      0x02a0   6c65 5f6e 756d 6265 7222 3a20 2222 2c20        le_number":."",.
      0x02b0   2272 6561 736f 6e22 3a20 6e75 6c6c 2c20        "reason":.null,.
      0x02c0   2272 6563 6f76 6572 795f 6279 5f71 7565        "recovery_by_que
      0x02d0   7374 696f 6e22 3a20 6661 6c73 652c 2022        stion":.false,."
      0x02e0   7265 736f 7572 6365 5f75 7269 223a 2022        resource_uri":."
      0x02f0   2f61 7069 2f76 312f 6c64 6170 7573 6572        /api/v1/ldapuser
      0x0300   732f 322f 222c 2022 7365 7276 6572 5f61        s/2/",."server_a
      0x0310   6464 7265 7373 223a 2022 7769 6e2d 6164        ddress":."win-ad
      0x0320   2e61 642e 6c61 6264 6f6d 6169 6e2e 636f        .ad.labdomain.co
      0x0330   6d22 2c20 2273 6572 7665 725f 6e61 6d65        m",."server_name
      0x0340   223a 2022 5749 4e2d 4144 222c 2022 746f        ":."WIN-AD",."to
      0x0350   6b65 6e5f 6175 7468 223a 2066 616c 7365        ken_auth":.false
      0x0360   2c20 2274 6f6b 656e 5f73 6572 6961 6c22        ,."token_serial"
      0x0370   3a20 2222 2c20 2274 6f6b 656e 5f74 7970        :."",."token_typ
      0x0380   6522 3a20 2222 2c20 2275 7365 726e 616d        e":."",."usernam
      0x0390   6522 3a20 226d 616e 6167 6572 4061 642e        e":."manager@ad.
      0x03a0   6c61 6264 6f6d 6169 6e2e 636f 6d22 7d2c        labdomain.com"},
      0x03b0   207b 2261 6374 6976 6522 3a20 6661 6c73        .{"active":.fals
      0x03c0   652c 2022 636f 6d70 616e 7922 3a20 224c        e,."company":."L
      0x03d0   4142 222c 2022 6465 7061 7274 6d65 6e74        AB",."department
      0x03e0   223a 2022 4e65 7453 6563 222c 2022 646e        ":."NetSec",."dn
      0x03f0   223a 2022 434e 3d61 646b 6576 696e 2c4f        ":."CN=adkevin,O
      0x0400   553d 5553 4552 532c 4f55 3d4c 4142 2c44        U=USERS,OU=LAB,D
      0x0410   433d 6164 2c44 433d 6c61 6264 6f6d 6169        C=ad,DC=labdomai
      0x0420   6e2c 4443 3d63 6f6d 222c 2022 656d 6169        n,DC=com",."emai
      0x0430   6c22 3a20 2261 646b 6576 696e 4061 642e        l":."adkevin@ad.
      0x0440   6c61 6264 6f6d 6169 6e2e 636f 6d22 2c20        labdomain.com",.
      0x0450   2266 6964 6f22 3a20 6661 6c73 652c 2022        "fido":.false,."
      0x0460   6669 7273 745f 6e61 6d65 223a 2022 6164        first_name":."ad
      0x0470   6b65 7669 6e22 2c20 2266 746d 5f61 6374        kevin",."ftm_act
      0x0480   5f6d 6574 686f 6422 3a20 2265 6d61 696c        _method":."email
      0x0490   222c 2022 6964 223a 2031 2c20 2269 735f        ",."id":.1,."is_
      0x04a0   6c6f 636b 6564 223a 2066 616c 7365 2c20        locked":.false,.
      0x04b0   226c 6173 745f 6e61 6d65 223a 2022 4c41        "last_name":."LA
      0x04c0   4222 2c20 226d 6f62 696c 655f 6e75 6d62        B",."mobile_numb
      0x04d0   6572 223a 2022 222c 2022 7265 6173 6f6e        er":."",."reason
      0x04e0   223a 2030 2c20 2272 6563 6f76 6572 795f        ":.0,."recovery_
      0x04f0   6279 5f71 7565 7374 696f 6e22 3a20 6661        by_question":.fa
      0x0500   6c73 652c 2022 7265 736f 7572 6365 5f75        lse,."resource_u
      0x0510   7269 223a 2022 2f61 7069 2f76 312f 6c64        ri":."/api/v1/ld
      0x0520   6170 7573 6572 732f 312f 222c 2022 7365        apusers/1/",."se
      0x0530   7276 6572 5f61 6464 7265 7373 223a 2022        rver_address":."
      0x0540   7769 6e2d 6164 2e61 642e 6c61 6264 6f6d        win-ad.ad.labdom
      0x0550   6169 6e2e 636f 6d22 2c20 2273 6572 7665        ain.com",."serve
      0x0560   725f 6e61 6d65 223a 2022 5749 4e2d 4144        r_name":."WIN-AD
      0x0570   222c 2022 746f 6b65 6e5f 6175 7468 223a        ",."token_auth":
      0x0580   2066 616c 7365 2c20 2274 6f6b 656e 5f73        .false,."token_s
      0x0590   6572 6961 6c22 3a20 2222 2c20 2274 6f6b        erial":."",."tok
      0x05a0   656e 5f74 7970 6522 3a20 2222 2c20 2275        en_type":."",."u
      0x05b0   7365 726e 616d 6522 3a20 2261 646b 6576        sername":."adkev
      0x05c0   696e 4061 642e 6c61 6264 6f6d 6169 6e2e        in@ad.labdomain.
      0x05d0   636f 6d22 7d5d 7d                              com"}]}

      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:

      1. Replace \r\n (line breaks) with no character using the Extended function
      2. Replace :. (colon dot) with . (dot)
      3. 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.

      JSON payload

      {
        "meta": {
          "limit": 10,
          "next": null,
          "offset": 0,
          "previous": null,
          "total_count": 2
        },
        "objects": [
          {
            "active": true,
            "company": null,
            "department": null,
            "dn": "CN=manager,OU=USERS,OU=LAB,DC=ad,DC=labdomain,DC=com",
            "email": "",
            "fido": false,
            "first_name": "manager",
            "ftm_act_method": "",
            "id": 2,
            "is_locked": false,
            "last_name": "",
            "mobile_number": "",
            "reason": null,
            "recovery_by_question": false,
            "resource_uri": "/api/v1/ldapusers/2/",
            "server_address": "win-ad.ad.labdomain.com",
            "server_name": "WIN-AD",
            "token_auth": false,
            "token_serial": "",
            "token_type": "",
            "username": "manager@ad.labdomain.com"
          },
          {
            "active": false,
            "company": "LAB",
            "department": "NetSec",
            "dn": "CN=adkevin,OU=USERS,OU=LAB,DC=ad,DC=labdomain,DC=com",
            "email": "adkevin@ad.labdomain.com",
            "fido": false,
            "first_name": "adkevin",
            "ftm_act_method": "email",
            "id": 1,
            "is_locked": false,
            "last_name": "LAB",
            "mobile_number": "",
            "reason": 0,
            "recovery_by_question": false,
            "resource_uri": "/api/v1/ldapusers/1/",
            "server_address": "win-ad.ad.labdomain.com",
            "server_name": "WIN-AD",
            "token_auth": false,
            "token_serial": "",
            "token_type": "",
            "username": "adkevin@ad.labdomain.com"
          }
        ]
      }

      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:

      1. Select them from a selector menu
      2. 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:

      FortiAnalyzer task to show how to switch from selector to manual input of variables

      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.

      FortiAuthenticator LDAP user 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

      FortiAnalyzer FortiOS connector status

      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:

      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.

      FortiGate automation action help

      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:

      1. Trigger on an event
      2. Execute a FortiOS webhook
      FortiAnalyzer FortiGate playbook

      The trigger matches on the event SSH-GOOGLE, and the event handler simply checks for SSH connections to the Google DNS server.

      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.

      faz_fgt_play_webhook_log

      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:

      1. The task itself
      2. 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.

      FortiAnalyzer FortiOS connector status with dynamic variables

      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:

      1. Select the variable from a selector menu
      2. 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}.

      FortiAnalyzer task variable selection or manual entering

      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:

      diagnose debug application autod -1
      diagnose debug enable

      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.

      FortiGate automation stitch log debug

      __action_cli_script_open()-171: cli script action:CREATE-BAD-HOST is called. svc ctx:0x55a1ff3aef80
      accprof:super_admin script:
      %%log%%
      
      __read_cli_script_result()-117: cli script:
      autod.3
       output:
      
      ========== #1, 2026-03-23 19:33:24 ==========
      FGT02  date=2026-03-23 time=19:33:24 eventtime=1774290803211381959 tz="+0100" logid="0100065301" type="event" subtype="system" level="notice" vd="root" logdesc="Internal Message" path="system" name="automation-stitch" action="webhook" mkey="Incoming Webhook Call" log="{ \"adom_name\": \"LAB\", \"adom_prefix\": \"FSFADOM198\", \"handler_name\": \"SSH-GOOGLE\", \"group_value\": \"192.168.1.231::\", \"event_id\": \"202603231000010013\", \"event_time\": \"1774290774\", \"devid\": \"FGVMSLTM26006351\", \"vdom\": \"root\", \"severity\": 2, \"epid\": \"3\", \"epname\": \"192.168.1.231\", \"epip\": \"192.168.1.231\", \"dst_epid\": \"101\", \"dst_epname\": \"8.8.8.8\", \"dst_epip\": \"8.8.8.8\", \"dvid\": \"1062\", \"euid\": \"3\", \"handler_type\": \"basic\", \"rule_name\": \"SSH-GOOGLE\", \"euname\": \"N\\\/A\", \"subject\": \"srcip:192.168.1.231<G>1:6:13<\\\/G>\", \"groupby1\": \"192.168.1.231\", \"logtype\": \"traffic\", \"devtype\": \"FortiGate\", \"extrainfo\": \"{ }\", \"targets\": [ { \"srcip\": \"192.168.1.231\" }
      Unknown action 0
      
      ======= end of #1, 2026-03-23 19:33:24 ======

      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 ======
      FortiGate bad group after automation stitch execution

      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.

    5. Introducing the FortiGate filesystem hash checker

      The important thing up front, here is the GitHub link to the FortiGate filesystem hash checker: https://github.com/KevinGuenay/fortigate-fs-hash-checker

      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.

      https://github.com/KevinGuenay/fortigate-fs-hash-checker

      Why would I use it?

      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.

      Fortinet is aware of these things and has implemented both pre- and post-boot mechanisms to check file integrity. Pre-boot, there is a BIOS-level signature and file integrity check and post-boot, there is a real-time file system integrity check, but checking all files is still a best practice.

      When do I use it?

      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.

      In the meantime, thank you, dear reader.

    6. My FortiGate best practices baseline

      The important thing up front, here is the GitHub link to the FortiGate best practices baseline: https://github.com/KevinGuenay/fortigate-baseline

      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.

    7. FortiLink over layer 2 with a third-party switch

      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.

      [Update 2026-05-02]: Since this post was written, Fortinet has created a technical tip article that focuses on this topic as well, except they use FortiSwitch management using HTTPS, which was introduced in 7.4.2. Check out this method, and maybe it works better for you: Technical Tip: Configuring FortiLink over 3rd party switches with L2 connectivity with the FortiGate

      The setup

      For this article, I have:

      • A FortiGate 60F on 7.6.5
      • 2x FortiSwitch 424E on 7.6.6
      • A FortiSwitch 108F on 7.6.6
      • An HPE Aruba CX 6000
      • An HPE Aruba 2530

      This should work with any enterprise third-party switch, because we only need VLANs, STP, and LACP.

      Layer 2 topology

      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:

      VLAN interface config

      Next, we pre-authorize the FortiSwitch via its serial number:

      Pre-auth FortiSwitch

      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).

      Auto negotiate from 10Mbps half-duplex

      As a final step on the FortiGate, we configure the LACP trunk port and the VLANs:

      FortiSwitch trunk creation
      VLAN configuration of trunk

      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.

      FortiSwitch connected at layer 2

      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.

      Basics of FortiLink

      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:

      Diagram to show VLAN configuration

      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.

      1. 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.
      2. Setting allowed VLANs to “all” on a FortiGate-managed FortiSwitch actually means a few different things, but never includes 4094.
      Error when setting the same management VLAN as an interface VLAN (GUI)
      Error when setting the same management VLAN as an interface VLAN (CLI)

      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).”.

      All possible VLANs to set

      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:

      1. You have a working layer 2 configuration, and your FortiSwitch connects to the FortiGate
      2. The configuration gets synced to the FortiSwitch
      3. The auto-network/auto topology “mgmt-vlan” gets set to 4094
      4. The native VLAN of the internal interface gets set to 4094
      5. 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
      6. 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.

      Expanded lab topology

      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
      1. We create the VLAN 4084 that gets used for the FortiSwitch’s native VLAN on the trunk
      2. We change the FortiLink management VLAN to 4083
      3. We disable VLAN optimization and set the allowed VLANs to all (1-4093)
      4. We pre-authorize the FortiSwitch
      5. 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
      1. We create a VLAN for FortiLink management and one for the FortiSwitch trunk
      2. We create an LACP LAG for the FortiGate with a native VLAN of 4083, which is the FortiLink management VLAN, and allow all VLANs
      3. We create an LACP LAG for the FortiSwitch with a native VLAN of 4084 and allow all VLANs
      4. 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
      1. We create an LACP trunk with a member port
      2. We configure the internal interface with the native VLAN of 4083, which matches the FortiLink VLAN
      3. 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:

      1. Pre-authorize the new FortiSwitch (FortiGate)
      2. Manually create the trunk interfaces on both FortiSwitches with the same configuration on the FortiGate (FortiGate)
      3. Manually create the trunk interface with the correct VLANs on the new FortiSwitch (FortiSwitch)
      4. Disable auto topology on the new FortiSwitch (FortiSwitch)
      5. 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.

      Lab topology

      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.

      LLDP profiles
      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:

      More third-party switches

      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.

    8. Going towards the deep end of a Fortinet ZTNA connection (TCP forwarding)

      What is ZTNA even?

      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

      ZTNA lab 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.

      ZTNA destinations

      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:

      FortiClient ZTNA configuration update

      [2025-11-30 17:36:24.4821744] [fortitcs] Configuration Changed!
      [2025-11-30 17:36:24.4824156] [fortitcs] PortalConfigChanged called due to config change!
      [2025-11-30 17:36:24.4830978] [fortitcs debug] DetectPAMRuleChanges::No changes detected: Old and new settings are identical
      [2025-11-30 17:36:24.4857283] [fortitcs] Configuration changed: bPortalChanged=false bConfigChanged=true force_reload_config=false
      [2025-11-30 17:36:24.4862689] [fortitcs] tcp forwarding enabled = 1
      [2025-11-30 17:36:24.4863362] [fortitcs] prefer_ipv4_over_ipv6: 0
      [2025-11-30 17:36:24.4864507] [fortitcs] enabled(set): false
      [2025-11-30 17:36:24.4864748] [fortitcs] newComponents32(set): 0
      [2025-11-30 17:36:24.4865335] [fortitcs] DisabledComponents(no set): 0
      [2025-11-30 17:36:24.4881639] [fortitcs] mask: 255.255.255.255 start: 192.168.1.241 end: 192.168.1.241
      [2025-11-30 17:36:24.4882016] [fortitcs] GetIpV4NetAddressRange-IpStart: 192.168.1.241
      [2025-11-30 17:36:24.4882213] [fortitcs] mask_changed: 255.255.255.255 start: 192.168.1.241 end: 192.168.1.241
      [2025-11-30 17:36:24.4883900] [fortitcs] orderRules:: No PAM Rule Change Identified, performing GW rules reload
      [2025-11-30 17:36:24.4884090] [fortitcs] ***
      [2025-11-30 17:36:24.4884208] [fortitcs] name: win-ad.ad.labdomain.com:3389
      [2025-11-30 17:36:24.4884311] [fortitcs] type: tcp-fwd
      [2025-11-30 17:36:24.4884412] [fortitcs] mode: transparent
      [2025-11-30 17:36:24.4884508] [fortitcs] destination: win-ad.ad.labdomain.com:3389
      [2025-11-30 17:36:24.4884600] [fortitcs] gateway: ztnaproxy.ad.labdomain.com:443
      [2025-11-30 17:36:24.4884705] [fortitcs] enable_udp: 0
      [2025-11-30 17:36:24.4884840] [fortitcs] latency: 8760h0m0s
      [2025-11-30 17:36:24.4884948] [fortitcs] encryption: 0
      [2025-11-30 17:36:24.4885051] [fortitcs] FQDN: win-ad.ad.labdomain.com
      [2025-11-30 17:36:24.4885156] [fortitcs] FQDN_flag: 0
      [2025-11-30 17:36:24.4885252] [fortitcs] IPStart: 10.235.0.1
      [2025-11-30 17:36:24.4885351] [fortitcs] IPEnd: 10.235.0.1
      [2025-11-30 17:36:24.4885452] [fortitcs] SubnetMask: 255.255.255.255
      [2025-11-30 17:36:24.4885551] [fortitcs] PortStart: 3389
      [2025-11-30 17:36:24.4885653] [fortitcs] PortEnd: 3389
      [2025-11-30 17:36:24.4885755] [fortitcs] Path:
      [2025-11-30 17:36:24.4885859] [fortitcs] ***
      [2025-11-30 17:36:24.4885970] [fortitcs] name: 192.168.1.241/255.255.255.255:3389
      [2025-11-30 17:36:24.4886958] [fortitcs] type: tcp-fwd
      [2025-11-30 17:36:24.4887140] [fortitcs] mode: transparent
      [2025-11-30 17:36:24.4887234] [fortitcs] destination: 192.168.1.241:3389
      [2025-11-30 17:36:24.4887332] [fortitcs] gateway: ztnaproxy.ad.labdomain.com:443
      [2025-11-30 17:36:24.4887983] [fortitcs] enable_udp: 0
      [2025-11-30 17:36:24.4888182] [fortitcs] latency: 8760h0m0s
      [2025-11-30 17:36:24.4888305] [fortitcs] encryption: 0
      [2025-11-30 17:36:24.4888411] [fortitcs] FQDN:
      [2025-11-30 17:36:24.4888511] [fortitcs] FQDN_flag: 0
      [2025-11-30 17:36:24.4888613] [fortitcs] IPStart: 192.168.1.241
      [2025-11-30 17:36:24.4888717] [fortitcs] IPEnd: 192.168.1.241
      [2025-11-30 17:36:24.4888824] [fortitcs] SubnetMask: 255.255.255.255
      [2025-11-30 17:36:24.4888926] [fortitcs] PortStart: 3389
      [2025-11-30 17:36:24.4889025] [fortitcs] PortEnd: 3389
      [2025-11-30 17:36:24.4889123] [fortitcs] Path:
      [2025-11-30 17:36:24.4889224] [fortitcs] ***
      [2025-11-30 17:36:24.4889318] [fortitcs] name: win-server.ad.labdomain.com:69
      [2025-11-30 17:36:24.4889417] [fortitcs] type: tcp-fwd
      [2025-11-30 17:36:24.4889516] [fortitcs] mode: transparent
      [2025-11-30 17:36:24.4889612] [fortitcs] destination: win-server.ad.labdomain.com:69
      [2025-11-30 17:36:24.4889705] [fortitcs] gateway: ztnaproxy.ad.labdomain.com:443
      [2025-11-30 17:36:24.4889805] [fortitcs] enable_udp: 1
      [2025-11-30 17:36:24.4889921] [fortitcs] latency: 8760h0m0s
      [2025-11-30 17:36:24.4890023] [fortitcs] encryption: 0
      [2025-11-30 17:36:24.4890114] [fortitcs] FQDN: win-server.ad.labdomain.com
      [2025-11-30 17:36:24.4890208] [fortitcs] FQDN_flag: 0
      [2025-11-30 17:36:24.4890300] [fortitcs] IPStart: 10.235.0.2
      [2025-11-30 17:36:24.4890450] [fortitcs] IPEnd: 10.235.0.2
      [2025-11-30 17:36:24.4890552] [fortitcs] SubnetMask: 255.255.255.255
      [2025-11-30 17:36:24.4890656] [fortitcs] PortStart: 69
      [2025-11-30 17:36:24.4890755] [fortitcs] PortEnd: 69
      [2025-11-30 17:36:24.4890855] [fortitcs] Path:
      [2025-11-30 17:36:24.4890956] [fortitcs] ***
      [2025-11-30 17:36:24.4891053] [fortitcs] name: win-server.ad.labdomain.com:3389
      [2025-11-30 17:36:24.4891147] [fortitcs] type: tcp-fwd
      [2025-11-30 17:36:24.4891242] [fortitcs] mode: transparent
      [2025-11-30 17:36:24.4891342] [fortitcs] destination: win-server.ad.labdomain.com:3389
      [2025-11-30 17:36:24.4891475] [fortitcs] gateway: ztnaproxy.ad.labdomain.com:443
      [2025-11-30 17:36:24.4891583] [fortitcs] enable_udp: 0
      [2025-11-30 17:36:24.4891690] [fortitcs] latency: 8760h0m0s
      [2025-11-30 17:36:24.4891791] [fortitcs] encryption: 0
      [2025-11-30 17:36:24.4891889] [fortitcs] FQDN: win-server.ad.labdomain.com
      [2025-11-30 17:36:24.4891987] [fortitcs] FQDN_flag: 0
      [2025-11-30 17:36:24.4892080] [fortitcs] IPStart: 10.235.0.2
      [2025-11-30 17:36:24.4892170] [fortitcs] IPEnd: 10.235.0.2
      [2025-11-30 17:36:24.4892262] [fortitcs] SubnetMask: 255.255.255.255
      [2025-11-30 17:36:24.4892355] [fortitcs] PortStart: 3389
      [2025-11-30 17:36:24.4892450] [fortitcs] PortEnd: 3389
      [2025-11-30 17:36:24.4892556] [fortitcs] Path:
      [2025-11-30 17:36:24.4892697] [fortitcs] ***
      [2025-11-30 17:36:24.4892891] [fortitcs] Found system dns clear tool: C:\WINDOWS\system32\ipconfig.exe
      [2025-11-30 17:36:24.4893146] [fortitcs] C:\WINDOWS\system32\ipconfig.exe /flushdns
      [2025-11-30 17:36:24.5234647] [fortitcs] [13 10 87 105 110 100 111 119 115 32 73 80 32 67 111 110 102 105 103 117 114 97 116 105 111 110 13 10 13 10 83 117 99 99 101 115 115 102 117 108 108 121 32 102 108 117 115 104 101 100 32 116 104 101 32 68 78 83 32 82 101 115 111 108 118 101 114 32 67 97 99 104 101 46 13 10]
      [2025-11-30 17:36:24.5414693] [fortitcs] UpdateDnsRedirectEntry: Ip=183173121 Port=3389 RealIp=0 Fqdn=win-ad.ad.labdomain.com Flag=0
      [2025-11-30 17:36:24.5415118] [fortitcs] UpdateDnsRedirectEntry: Ip=183173122 Port=69 RealIp=0 Fqdn=win-server.ad.labdomain.com Flag=0
      [2025-11-30 17:36:24.5415282] [fortitcs] UpdateDnsRedirectEntry: Ip=183173122 Port=3389 RealIp=0 Fqdn=win-server.ad.labdomain.com Flag=0
      [2025-11-30 17:36:24.5416140] [fortitcs] Update DNS redirect Entry!
      [2025-11-30 17:36:24.5416268] [fortitcs] Initialize UDP-DNS Success!
      [2025-11-30 17:36:24.5416337] [fortitcs] Initialize UDP-Proxy Success!
      [2025-11-30 17:36:24.5416437] [fortitcs] TCP Forwarding: IpStart: 10.235.0.1 IpEnd: 10.235.0.1 PortStart: 3389 PortEnd: 3389
      [2025-11-30 17:36:24.5416519] [fortitcs] TCP Forwarding: IpStart: 192.168.1.241 IpEnd: 192.168.1.241 PortStart: 3389 PortEnd: 3389
      [2025-11-30 17:36:24.5416589] [fortitcs] TCP Forwarding: IpStart: 10.235.0.2 IpEnd: 10.235.0.2 PortStart: 69 PortEnd: 69
      [2025-11-30 17:36:24.5416669] [fortitcs] TCP Forwarding: IpStart: 10.235.0.2 IpEnd: 10.235.0.2 PortStart: 3389 PortEnd: 3389
      [2025-11-30 17:36:24.5416741] [fortitcs] DOH Forwarding: IpStart: 16777217 IpEnd: 16777217 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5416807] [fortitcs] DOH Forwarding: IpStart: 16843009 IpEnd: 16843009 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5416874] [fortitcs] DOH Forwarding: IpStart: 134743044 IpEnd: 134743044 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5416937] [fortitcs] DOH Forwarding: IpStart: 134744072 IpEnd: 134744072 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5416998] [fortitcs] DOH Forwarding: IpStart: 151587081 IpEnd: 151587081 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417086] [fortitcs] DOH Forwarding: IpStart: 3741648133 IpEnd: 3741648133 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417153] [fortitcs] DOH Forwarding: IpStart: 1746021035 IpEnd: 1746021035 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417225] [fortitcs] DOH Forwarding: IpStart: 2889912661 IpEnd: 2889912661 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417290] [fortitcs] DOH Forwarding: IpStart: 1745942777 IpEnd: 1745942777 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417357] [fortitcs] DOH Forwarding: IpStart: 1745943033 IpEnd: 1745943033 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417419] [fortitcs] DOH Forwarding: IpStart: 2456824066 IpEnd: 2456824066 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417482] [fortitcs] DOH Forwarding: IpStart: 3118770344 IpEnd: 3118770344 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417541] [fortitcs] DOH Forwarding: IpStart: 3118770186 IpEnd: 3118770186 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417603] [fortitcs] DOH Forwarding: IpStart: 3165443477 IpEnd: 3165443477 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417665] [fortitcs] DOH Forwarding: IpStart: 1747227829 IpEnd: 1747227829 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417731] [fortitcs] DOH Forwarding: IpStart: 2507176202 IpEnd: 2507176202 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417795] [fortitcs] DOH Forwarding: IpStart: 2507176458 IpEnd: 2507176458 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417860] [fortitcs] DOH Forwarding: IpStart: 2507176212 IpEnd: 2507176212 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417937] [fortitcs] DOH Forwarding: IpStart: 2507176468 IpEnd: 2507176468 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5417999] [fortitcs] DOH Forwarding: IpStart: 2507176222 IpEnd: 2507176222 PortStart: 443 PortEnd: 443
      [2025-11-30 17:36:24.5418063] [fortitcs] DOH Forwarding: IpStart: 2507176478 IpEnd: 2507176478 PortStart: 443 PortEnd: 443

      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.

      Ping FQDN that is a ZTNA destination

      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:

      FortiClient FQDN ZTNA access log

      [2025-11-30 15:12:02.0717327] [fortitcs] Accept success
      [2025-11-30 15:12:02.0718489] [fortitcs] family: 2 port: 3389 ip: 10.235.0.1 pid: 6904
      [2025-11-30 15:12:02.0718881] [fortitcs] forward ip: 10.235.0.1 port: 3389
      [2025-11-30 15:12:02.0720149] [fortitcs] FindFQDNFromDoh: IP=10.235.0.1 Port=3389 FQDN=win-server.ad.labdomain.com
      [2025-11-30 15:12:02.0720306] [fortitcs] UpdateDnsRedirectEntry: Ip=183173121 Port=3389 RealIp=0 Fqdn=win-server.ad.labdomain.com Flag=0
      [2025-11-30 15:12:02.0720584] [fortitcs] UpdateFQDNIpAndPort: get real_ip: 10.235.0.1fqdn: win-server.ad.labdomain.com
      [2025-11-30 15:12:02.0721258] [fortitcs] Request is not for PAM Agent. Ip: 10.235.0.1, Port: 3389, FQDN: win-server.ad.labdomain.com
      [2025-11-30 15:12:02.0721942] [fortitcs] disallow_invalid_server_certificate: 0
      [2025-11-30 15:12:02.0782044] [fortitcs] gateway ip: ztnaproxy.ad.labdomain.com port: 443encryption: 0 cur_thread: 4568
      [2025-11-30 15:12:02.1321076] [fortitcs] handshake success!
      [2025-11-30 15:12:02.1321540] [fortitcs] SAML address: https://ztnaproxy.ad.labdomain.com:443/tcp?address=win-server.ad.labdomain.com&port=3389&tls=0
      [2025-11-30 15:12:02.1321638] [fortitcs] Cache address: https://ztnaproxy.ad.labdomain.com:443
      [2025-11-30 15:12:02.1323757] [fortitcs] Request: GET /tcp?address=win-server.ad.labdomain.com&port=3389&tls=0 HTTP/1.1
      Host: ztnaproxy.ad.labdomain.com
      Accept: /
      Authorization:
      Cookie:
      User-Agent: Forticlient
      X-Fos-Auth:
      
      [2025-11-30 15:12:02.1324585] [fortitcs] Send tunnel cmd Success
      [2025-11-30 15:12:02.1343570] [fortitcs] response code: 101 html Reply:
      [2025-11-30 15:12:02.1344669] [fortitcs] Tunnel upgrade success
      [2025-11-30 15:12:02.6492340] [fortitcs] Forward win-server.ad.labdomain.com:: local: 127.0.0.1:49689 -> remote: 127.0.0.1:50242proxy: 172.31.1.2:50243 -> remote: 172.31.1.1:443

      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.

      FortiClient ZTNA IP access log

      [2025-11-30 15:31:46.2775951] [fortitcs] Accept success
      [2025-11-30 15:31:46.2777679] [fortitcs] family: 2 port: 3389 ip: 192.168.1.241 pid: 6904
      [2025-11-30 15:31:46.2778108] [fortitcs] forward ip: 192.168.1.241 port: 3389
      [2025-11-30 15:31:46.2778936] [fortitcs] Request is not for PAM Agent. Ip: 192.168.1.241, Port: 3389, FQDN: 
      [2025-11-30 15:31:46.2779464] [fortitcs] disallow_invalid_server_certificate: 0
      [2025-11-30 15:31:46.2918114] [fortitcs] gateway ip: ztnaproxy.ad.labdomain.com port: 443encryption: 0 cur_thread: 9132
      [2025-11-30 15:31:46.3503269] [fortitcs] handshake success!
      [2025-11-30 15:31:46.3503960] [fortitcs] SAML address: https://ztnaproxy.ad.labdomain.com:443/tcp?address=192.168.1.241&port=3389&tls=0
      [2025-11-30 15:31:46.3506344] [fortitcs] Cache address: https://ztnaproxy.ad.labdomain.com:443
      [2025-11-30 15:31:46.3509011] [fortitcs] Request: GET /tcp?address=192.168.1.241&port=3389&tls=0 HTTP/1.1
      Host: ztnaproxy.ad.labdomain.com
      Accept: */*
      Authorization: ******
      Cookie: ******
      User-Agent: Forticlient
      X-Fos-Auth: 
      
      
      [2025-11-30 15:31:46.3510320] [fortitcs] Send tunnel cmd Success
      [2025-11-30 15:31:46.3523777] [fortitcs] response code: 101 html Reply: 
      [2025-11-30 15:31:46.3524196] [fortitcs] Tunnel upgrade success
      [2025-11-30 15:31:46.8645496] [fortitcs] Forward :: local: 127.0.0.1:49689 -> remote: 127.0.0.1:50646proxy: 172.31.1.2:50647 -> remote: 172.31.1.1:443

      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:

      diagnose wad debug enable category session
      diagnose wad debug enable category policy
      diagnose wad debug enable category http
      diagnose wad debug enable category vs
      diagnose wad debug enable category ssl
      diagnose wad debug enable category quic
      diagnose debug enable

      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):

      [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
      Serial of endpoint

      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.

      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:

      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:

      [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

      After that, the FortiGate tries to match a policy, since it now has the 5-tuple:

      [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)

      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:

      Capture to show no NAT

      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:

      Capture to show active NAT

      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:

      1. Certificate validation
      2. An optional step for DNS resolution
      3. Policy matching
      4. 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

      FortiClient ZTNA UDP

      [2025-11-30 18:50:16.7098277] [fortitcs] FindFQDNFromDoh: IP=10.235.0.2 Port=53 FQDN=win-server.ad.labdomain.com
      [2025-11-30 18:50:16.7099651] [fortitcs] UpdateDnsRedirectEntry: Ip=183173122 Port=53 RealIp=0 Fqdn=win-server.ad.labdomain.com Flag=0
      [2025-11-30 18:50:16.7100409] [fortitcs] UpdateFQDNIpAndPort: get real_ip: 10.235.0.2fqdn: win-server.ad.labdomain.com
      [2025-11-30 18:50:16.7100897] [fortitcs] Establish: ztnaproxy.ad.labdomain.com:443
      [2025-11-30 18:50:16.7102869] [fortitcs] strPort: 53
      [2025-11-30 18:50:16.7105815] [fortitcs] Request: GET /tcp?address=win-server.ad.labdomain.com&port=53&proto=udp HTTP/1.1
      Host: ztnaproxy.ad.labdomain.com:443
      Accept: /
      User-Agent: Forticlient
      X-Fos-Auth:
      
      [2025-11-30 18:50:16.7105917] [fortitcs] Establish: ph2
      [2025-11-30 18:50:16.7106238] [fortitcs] disallow_invalid_server_certificate: 0
      [2025-11-30 18:50:16.7106342] [fortitcs] Establish: ph3
      [2025-11-30 18:50:16.7106407] [fortitcs] tls_config.InsecureSkipVerify: true
      [2025-11-30 18:50:16.7106714] [fortitcs] Establishing new connection to ztnaproxy.ad.labdomain.com:443
      [2025-11-30 18:50:16.7776034] [fortitcs] disallow_invalid_server_certificate: 0
      [2025-11-30 18:50:16.7900419] [fortitcs] Not found virtual IP for ztnaproxy.ad.labdomain.com.
      [2025-11-30 18:50:16.8184412] [fortitcs] Handshake complete for 172.31.1.1:443
      [2025-11-30 18:50:16.8319126] [fortitcs] Establish: RoundTripOpt end!
      [2025-11-30 18:50:16.8320560] [fortitcs] Establish: ph4
      [2025-11-30 18:50:16.8320814] [fortitcs] Establish: ph5
      [2025-11-30 18:50:16.8320925] [fortitcs] Establish: ph6
      [2025-11-30 18:50:16.8321824] [fortitcs] Establish: ph7
      [2025-11-30 18:50:16.8322048] [fortitcs] Establish: ph8
      [2025-11-30 18:50:16.8322226] [fortitcs] Establish: ph9
      [2025-11-30 18:50:16.8322504] [fortitcs] Establish: ph10

      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:

      FortiGate ZTNA UDP

      [V][p:3291] wad_quic_sock_listener_on_event :1070 start processing QUIC event=0x1 events=0x1 fd=36
      [I][p:3291] wad_quic_sock_rx :514 |sock 36|recv packet|ecn 0|bytes 1252|
      [I][p:3291] wad_quic_sock_rx :521 |src:172.31.1.2:65309->dst:172.31.1.1:443|ifindex 8|
      [V][p:3291][s:40194] wad_vs_proxy_match_vhost :4662 675:ZTNA-LAB: matching vhost by: ztnaproxy.ad.labdomain.com
      [V][p:3291][s:40194] wad_vs_matcher_map_find :798 Empty matcher!
      [V][p:3291][s:40194] wad_vs_proxy_match_vhost :4665 675:ZTNA-LAB: no host matched.
      [I][p:3291][s:40194] wad_quic_tls_c2vs_cert_req_add_trust_issuers:5681 adding subject: C = CA, ST = California, L = Sunnyvale, O = Fortinet, OU = 00000000000000000000000000000000, CN = FCTEMS8824011503 to CCR.
      [V][p:3291][s:40194] wad_key_share_bucket_get_entry :172 key_share 0x7f84d23c48 g=29 use_cnt=1 holding=1 reuseable 1
      [I][p:3291] wad_quic_tls_c2vs_ap_on_certs :5468 conn:0x7f84fd4048|c2vs| received certs from the client.
      [I][p:3291] __wad_ssl_cert_open_quic_cert :710 https server uses key_len 2048
      [I][p:3291] wad_quic_tls_c2vs_ap_on_certs :5478 conn:0x7f84fd4048|c2vs| cert cache cert(0x7f83725ed8) authi(0x7f8401e638)
      [I][p:3291] wad_ssl_cert_check_auth_status_with_ca_store:464 authi(0x7f8401e638) status(0)
      [I][p:3291] wad_ssl_cert_parse_cert :1448 EMS SN=FCTEMS8824011503
      [I][p:3291] wad_ssl_cert_parse_cert :1453 Tenant ID=00000000000000000000000000000000
      [I][p:3291] wad_ssl_cert_get_sn :1364 ci(0x7f83452f40): sn=3AA3BF4801BF5771BF3079CCDFB7F66DF0841E6F
      [I][p:3291] wad_ssl_validate_cert_by_cas :3365 Certificate verified!
      [I][p:3291] wad_ssl_validate_cert_by_ca_store :3421 Single cert verified Done!
      [I][p:3291] wad_quic_tls_c2vs_ap_on_certs :5517 conn:0x7f84fd4048|c2vs| Cert auth success. issued_by: ems
      [V][p:3291] wad_quic_sock_listener_on_event :1070 start processing QUIC event=0x1 events=0x1 fd=36
      [V][p:3291] wad_quic_conn_rx_hs :8615 conn:0x7f84fd4048|c2vs| conn: 0x7f84fd4048 dcid: path: src:172.31.1.2:65309 dst:172.31.1.1:443 activated
      [V][p:3291][s:40194][r:33554433] wad_http_req_alloc :1911 req=0x7f84fa3048(ses_ctx:t|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=0x7f84d4a228 clt_port=0x555cde52d8 svr_port=0x555cde52d8
      [I][p:3291][s:40194][r:33554433] wad_dump_http_request :3066 hreq=0x7f84fa3048 Received request from client: 172.31.1.2:65309
      
      GET /tcp?address=win-server.ad.labdomain.com&port=53&proto=udp HTTP/3.0
      Host: ztnaproxy.ad.labdomain.com:443
      x-fos-auth:
      user-agent: Forticlient
      accept: /
      accept-encoding: gzip
      
      [V][p:3291][s:40194][r:33554433] wad_http_marker_uri :1526 path=/tcp len=4
      [V][p:3291][s:40194][r:33554433] wad_http_parse_host :1904 host_len=30
      [V][p:3291][s:40194][r:33554433] wad_http_parse_host :1946 len=26
      [V][p:3291][s:40194][r:33554433] wad_http_parse_host :1955 len=3
      [I][p:3291][s:40194][r:33554433] wad_http_str_canonicalize :2466 enc=0 path=/tcp len=4 changes=0
      [I][p:3291][s:40194][r:33554433] wad_http_str_canonicalize :2468 end=4 path=address=win-server.ad.labdomain.com&port=53&proto=udp len=53 changes=0
      [V][p:3291][s:40194][r:33554433] wad_http_normalize_uri :2705 host_len=26 path_len=4 query_len=53
      [I][p:3291][s:40194][r:33554433] wad_http_req_detect_special :14378 captive_portal detected: false, preflight=(null)
      [I][p:3291][s:40194][r:33554433] wad_vs_proxy_match_gwy :4543 675:ZTNA-LAB: matching gwy with vhost(def_virtual_host)
      [V][p:3291][s:40194][r:33554433] wad_vs_proxy_match_vhost :4662 675:ZTNA-LAB: matching vhost by: ztnaproxy.ad.labdomain.com
      [V][p:3291][s:40194][r:33554433] wad_vs_matcher_map_find :798 Empty matcher!
      [V][p:3291][s:40194][r:33554433] wad_vs_proxy_match_vhost :4665 675:ZTNA-LAB: no host matched.
      [I][p:3291][s:40194][r:33554433] wad_vs_proxy_match_gwy :4611 675:ZTNA-LAB: matching gwy by (/tcp) with vhost(def_virtual_host).
      [I][p:3291][s:40194][r:33554433] wad_vs_proxy_match_gwy :4630 675:ZTNA-LAB: Matched gwy(1) type(tcp-fwd).
      [I][p:3291][s:40194][r:33554433] wad_vs_gwy_tcp_dst_ovrd :3382 675:ZTNA-LAB:1: req(0x7f84fa3048) query(address=win-server.ad.labdomain.com&port=53&proto=udp)
      [I][p:3291][s:40194][r:33554433] wad_vs_gwy_tcp_get_parameters :3136 675:ZTNA-LAB:1: got fqdn=win-server.ad.labdomain.com.
      [I][p:3291][s:40194][r:33554433] wad_vs_gwy_tcp_get_parameters :3166 675:ZTNA-LAB:1: failed to get TLS, set to 0.
      [V][p:3291][s:40194][r:33554433] wad_vs_matcher_map_find :798 Empty matcher!
      [W][p:3291][s:40194][r:33554433] wad_vs_proxy_dns_resolve :3290 req(0x7f84fa3048) vs DNS request name=win-server.ad.labdomain.com len=27 type/pref=0/0
      [I][p:3291][s:40194][r:33554433] wad_vs_proxy_dns_request_done :3237 req(0x7f84fa3048) vs DNS resolved: 192.168.1.241
      [I][p:3291][s:40194][r:33554433] wad_http_req_exec_on_vs_dns_ready :12441 req(0x7f84fa3048) vs DNS ready: dns_resolved(1), domain_matched(0), addr_matched(1)
      [V][p:3291][s:40194][r:33554433] wad_http_req_get_dst_intf :12196 vd=0 dst=192.168.1.241 ifidx=5
      [V][p:3291][s:40194][r:33554433] wad_http_req_vs_check_policy :12369 HTTP req=0x7f84fa3048 out_intf=5, vwl=0
      [V][p:3291][s:40194][r:33554433] wad_http_req_check_policy_with_flags:12021 start match policy vd=0(ses_ctx:t|Pv|M|H|C|A1|O) (172.31.1.2:65309@8->192.168.1.241:53@5) absUrl=0
      [V][p:3291][s:40194][r:33554433] wad_fw_addr_match_ap :1292 matching ap:ZTNA-LAB(8) with vip addr:ZTNA-LAB(8)
      [I][p:3291][s:40194][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:3291][s:40194][r:33554433] wad_fw_policy_async_match :5929 pol_ctx:th|Ad|7|=d
      [I][p:3291][s:40194][r:33554433] wad_http_req_policy_set :10156 match policy-id=123(pol_ctx:th|Ad|7|=d) vd=0(ses_ctx:t|Pv|Me|H|C|A1|O) (172.31.1.2:65309@8 -> 192.168.1.241:53@5)
      [V][p:3291][s:40194][r:33554433] wad_https_ap_pol_info_get :9957 policy info created, req=0x7f84fa3048, ses_ctx=0x7f836b30c8, info=0x7f84009710
      [W][p:3291][s:40194][r:33554433] wad_http_srv_slct_no_connect_get_connectable:2179 trace
      [I][p:3291][s:40194][r:33554433] wad_http_req_proc_policy :9635 ses_ctx:t|Pvx|Me|H|C|A1|O conn_srv=0 fwd_srv=
      [I][p:3291][s:40194][r:33554433] wad_http_req_proc_policy :9770 policy result:vf_id=0:0 sec_profile=0x7f836e09c8 set_cookie=0
      [V][p:3291][s:40194][r:33554433] wad_http_srv_slct_no_connect_get_srv:2145 trace
      [I][p:3291][s:40194][r:33554433] wad_http_upd_ses_ctx_by_req :948 wad http session 0x7f84d4a228 forward (nil) fwd_srv_ip=
      [V][p:3291][s:40194][r:33554433] wad_http_req_get_svr :7981 http session 0x7f84d4a228 req=0x7f84fa3048 connected
      [V][p:3291][s:40194][r:33554433] wad_http_msg_start_setup_proc :2131 msg(0x7f84fa3048) proc-setup started from: req_vs.
      [V][p:3291][s:40194][r:33554433] wad_http_def_proc_msg_plan :2093 msg(0x7f84fa3048) setting up processor(req_vs)
      [I][p:3291][s:40194][r:33554433] wad_http_req_check_vs_tunnel_type :3319 try to match HTTPS/HTTP/SSH/CIFS/DOT with nport=53
      [I][p:3291][s:40194][r:33554433] wad_http_req_check_vs_tunnel_type :3349 port=53, proto=-1, protocol not matched.
      [I][p:3291][s:40194][r:33554433] wad_http_req_check_vs_tunnel_type :3359 Check redir PROXY port=53((null))
      [I][p:3291][s:40194][r:33554433] wad_http_req_check_vs_tunnel_type :3367 TCP tunnel detected without type.
      [V][p:3291][s:40194][r:33554433] wad_http_req_convert_tfap_tunnel :3211 req(0x7f84fa3048) is converting to VS forwarding tunnel.
      [V][p:3291][s:40194][r:33554433] wad_http_build_tfap_resp :653 ua=Forticlient
      [I][p:3291][s:40194][r:33554433] wad_http_req_set_tfap_param :3193 req:0x7f84fa3048 got tunnel param type:unknown proto:tcp/udp off_ssl:1
      [I][p:3291][s:40194][r:33554433] wad_dump_fwd_http_resp :3087 hreq=0x7f84fa3048 Forward response from Internal:
      
      HTTP/1.1 101 Switching Protocols
      Upgrade: tcp-forwarding/1.0
      Connection: Upgrade
      
      [I][p:3291][s:40194][r:33554433] wad_http_req_resp_fwd_done :3443 req(0x7f84fa3048) resp(0x7f837f0b00/(nil)) resp-fwd done!
      [V][p:3291][s:40194][r:33554433] wad_http_resp_msg_fwd_done :1828 resp(0x7f837f0b00) forwarding done! shmstrm((nil))
      [I][p:3291][s:40194][r:33554433] wad_http_req_finished :1574 req=0x7f84fa3048 cstrm=0x7f84d4a2d0 req_done=1 done_close=0
      [I][p:3291][s:40194][r:33554433] __wad_http_req_close :2053 ret = -1!
      [I][p:3291][s:33554433] wad_http_req_convert_tfap_tunnel :3258 Converted to tfap tunnel.

      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.

      Wireshark to show encryption vs. no encryption

      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.

      FortiClient ZTNA encrypted

      [2025-12-04 18:09:26.0012819] [fortitcs] Accept success
      [2025-12-04 18:09:26.0013202] [fortitcs] raw socket: 1644
      [2025-12-04 18:09:26.0016489] [fortitcs] status: recordsSize: 988
      [2025-12-04 18:09:26.0017080] [fortitcs] redirectContextSize: 260
      [2025-12-04 18:09:26.0017550] [fortitcs] contextSize: 260
      [2025-12-04 18:09:26.0017765] [fortitcs] family: 2 port: 80 ip: 10.235.0.2 pid: 12112
      [2025-12-04 18:09:26.0019011] [fortitcs] forward ip: 10.235.0.2 port: 80
      [2025-12-04 18:09:26.0019647] [fortitcs] FindFQDNFromDoh: IP=10.235.0.2 Port=80 FQDN=win-server.ad.labdomain.com
      [2025-12-04 18:09:26.0019802] [fortitcs] UpdateDnsRedirectEntry: Ip=183173122 Port=80 RealIp=0 Fqdn=win-server.ad.labdomain.com Flag=0
      [2025-12-04 18:09:26.0020077] [fortitcs] UpdateFQDNIpAndPort: get real_ip: 10.235.0.2fqdn: win-server.ad.labdomain.com
      [2025-12-04 18:09:26.0020579] [fortitcs] Request is not for PAM Agent. Ip: 10.235.0.2, Port: 80, FQDN: win-server.ad.labdomain.com
      [2025-12-04 18:09:26.0020805] [fortitcs] According to PID 12112 Get session ID: 5
      [2025-12-04 18:09:26.0021202] [fortitcs] disallow_invalid_server_certificate: 0
      [2025-12-04 18:09:26.0032192] [fortitcs] gateway ip: 172.31.1.1 port: 443encryption: 1 cur_thread: 6288
      [2025-12-04 18:09:26.0102319] [fortitcs] UpdateFQDNIpAndPort: get real_ip: 192.168.1.240 fqdn:
      [2025-12-04 18:09:26.0103274] [fortitcs] enable_udp == 0
      [2025-12-04 18:09:26.0104330] [fortitcs] UpdateFQDNIpAndPort: get real_ip: 192.168.1.240 fqdn:
      [2025-12-04 18:09:26.0105029] [fortitcs] enable_udp == 0
      [2025-12-04 18:09:26.0407382] [fortitcs] nSession(5)
      [2025-12-04 18:09:26.0413592] [fortitcs] ImpersonateLoggedOnUser success! cur_thread = 6288
      [2025-12-04 18:09:26.0445245] [fortitcs] trace reinit -> pid: 10132
      [2025-12-04 18:09:26.0746709] [fortitcs] handshake success!
      [2025-12-04 18:09:26.0748232] [fortitcs] SAML address: https://172.31.1.1:443/tcp?address=win-server.ad.labdomain.com&port=80&tls=1
      [2025-12-04 18:09:26.0748731] [fortitcs] Cache address: https://172.31.1.1:443
      [2025-12-04 18:09:26.0751770] [fortitcs debug] GetSAMLAuth: no token found for gateway: 172.31.1.1:443
      [2025-12-04 18:09:26.0753055] [fortitcs debug] GetFormAuth: no token found for gateway: 172.31.1.1:443
      [2025-12-04 18:09:26.0757257] [fortitcs] Request: GET /tcp?address=win-server.ad.labdomain.com&port=80&tls=1 HTTP/1.1
      Host: 172.31.1.1
      Accept: /
      Authorization:
      Cookie:
      User-Agent: Forticlient
      X-Fos-Auth:
      
      [2025-12-04 18:09:26.0760973] [fortitcs] Send tunnel cmd Success
      [2025-12-04 18:09:26.0784150] [fortitcs] response code: 101 html Reply:
      [2025-12-04 18:09:26.0786411] [fortitcs] Tunnel upgrade success
      [2025-12-04 18:09:26.0786899] [fortitcs] CallUpdateStatus call! mod=4 status=0 msgType=0
      [2025-12-04 18:09:26.0825090] [fortitcs] procUpdateStatus: mod=4 status=0 msgType=0 msg.Extra= ret = 1
      [2025-12-04 18:09:26.0827333] [fortitcs] Forward win-server.ad.labdomain.com:: local: 127.0.0.1:61022 -> remote: 127.0.0.1:57137proxy: 172.31.1.2:57138 -> remote: 172.31.1.1:443

      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

      We can see a few failure messages here:

      [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)

      And a 403 Forbidden error gets returned:

      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.

      Error: No EMS connection

      This HTML is built by FortiTCS, along with some other error messages.

      FortiClient certificate validation failure

      [2025-12-04 18:33:57.9286264] [fortitcs] Accept success
      [2025-12-04 18:33:57.9286556] [fortitcs] raw socket: 1716
      [2025-12-04 18:33:57.9286778] [fortitcs] status: <nil> recordsSize: 926
      [2025-12-04 18:33:57.9286847] [fortitcs] redirectContextSize: 260
      [2025-12-04 18:33:57.9286928] [fortitcs] contextSize: 260
      [2025-12-04 18:33:57.9287007] [fortitcs] family: 2 port: 3389 ip: 10.235.0.2 pid: 11896
      [2025-12-04 18:33:57.9287344] [fortitcs] forward ip: 10.235.0.2 port: 3389
      [2025-12-04 18:33:57.9287713] [fortitcs] FindFQDNFromDoh: IP=10.235.0.2 Port=3389 FQDN=win-server.ad.labdomain.com
      [2025-12-04 18:33:57.9287848] [fortitcs] UpdateDnsRedirectEntry: Ip=183173122 Port=3389 RealIp=0 Fqdn=win-server.ad.labdomain.com Flag=0
      [2025-12-04 18:33:57.9288140] [fortitcs] UpdateFQDNIpAndPort: get real_ip: 10.235.0.2fqdn: win-server.ad.labdomain.com
      [2025-12-04 18:33:57.9289428] [fortitcs] Request is not for PAM Agent. Ip: 10.235.0.2, Port: 3389, FQDN: win-server.ad.labdomain.com
      [2025-12-04 18:33:57.9289660] [fortitcs] According to PID 11896 Get session ID: 5
      [2025-12-04 18:33:57.9289971] [fortitcs] disallow_invalid_server_certificate: 0
      [2025-12-04 18:33:57.9296716] [fortitcs] gateway ip: 172.31.1.1 port: 443encryption: 0 cur_thread: 11364
      [2025-12-04 18:33:57.9666418] [fortitcs] nSession(5)
      [2025-12-04 18:33:57.9669035] [fortitcs] ImpersonateLoggedOnUser success!  cur_thread = 11364
      [2025-12-04 18:33:57.9684843] [fortitcs] trace reinit -> pid: 10132
      [2025-12-04 18:33:57.9924492] [fortitcs] handshake success!
      [2025-12-04 18:33:57.9925337] [fortitcs] SAML address: https://172.31.1.1:443/tcp?address=win-server.ad.labdomain.com&port=3389&tls=0
      [2025-12-04 18:33:57.9925562] [fortitcs] Cache address: https://172.31.1.1:443
      [2025-12-04 18:33:57.9926872] [fortitcs debug] GetSAMLAuth: no token found for gateway: 172.31.1.1:443
      [2025-12-04 18:33:57.9927247] [fortitcs debug] GetFormAuth: no token found for gateway: 172.31.1.1:443
      [2025-12-04 18:33:57.9929090] [fortitcs] Request: GET /tcp?address=win-server.ad.labdomain.com&port=3389&tls=0 HTTP/1.1
      Host: 172.31.1.1
      Accept: */*
      Authorization: ******
      Cookie: ******
      User-Agent: Forticlient
      X-Fos-Auth: 
      
      
      [2025-12-04 18:33:57.9930282] [fortitcs] Send tunnel cmd Success
      [2025-12-04 18:33:58.0005314] [fortitcs] response code: 403 html Reply: <!DOCTYPE html>
      <html lang="en">
          <head>
              <meta charset="UTF-8">
              <meta http-equiv="X-UA-Compatible" content="IE=8; IE=EDGE">
              <meta name="viewport" content="width=device-width, initial-scale=1">
              <style type="text/css">
                  body {
                      height: 100%;
                      font-family: Helvetica, Arial, sans-serif;
                      color: #6a6a6a;
                      margin: 0;
                      display: flex;
                      align-items: center;
                      justify-content: center;
                  }
                  input[type=date], input[type=email], input[type=number], input[type=password], input[type=search], input[type=tel], input[type=text], input[type=time], input[type=url], select, textarea {
                      color: #262626;
                      vertical-align: baseline;
                      margin: .2em;
                      border-style: solid;
                      border-width: 1px;
                      border-color: #a9a9a9;
                      background-color: #fff;
                      box-sizing: border-box;
                      padding: 2px .5em;
                      appearance: none;
                      border-radius: 0;
                  }
                  input:focus {
                      border-color: #646464;
                      box-shadow: 0 0 1px 0 #a2a2a2;
                      outline: 0;
                  }
                  button {
                      padding: .5em 1em;
                      border: 1px solid;
                      border-radius: 3px;
                      min-width: 6em;
                      font-weight: 400;
                      font-size: .8em;
                      cursor: pointer;
                  }
                  button.primary {
                      color: #fff;
                      background-color: rgb(47, 113, 178);
                      border-color: rgb(34, 103, 173);
                  }
                  .message-container {
                      height: 500px;
                      width: 500px;
                      padding: 0;
                      margin: 10px;
                  }
                  .logo {
                      background: url('data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAA...') no-repeat left center;
                      height: 267px;
                      object-fit: contain;
                  }
                  table {
                      background-color: #fff;
                      border-spacing: 0;
                      margin: 1em;
                  }
                  table > tbody > tr > td:first-of-type:not([colspan]) {
                      color: rgba(0,0,0,.5);
                  }
                  table > tbody > tr > td:first-of-type {
                      vertical-align: top;
                  }
                  table > tbody > tr > td {
                      padding: .3em .3em;
                  }
                  .field {
                      display: table-row;
                  }
                  .field > :first-child {
                      display: table-cell;
                      width: 20%;
                  }
                  .field.single > :first-child {
                      display: inline;
                  }
                  .field > :not(:first-child) {
                      width: auto;
                      max-width: 100%;
                      display: inline-flex;
                      align-items: baseline;
                      virtical-align: top;
                      box-sizing: border-box;
                      margin: .3em;
                  }
                  .field > :not(:first-child) > input {
                      width: 230px;
                  }
                  .form-footer {
                      display: inline-flex;
                      justify-content: flex-start;
                  }
                  .form-footer > * {
                      margin: 1em;
                  }
                  .text-scrollable {
                      overflow: auto;
                      height: 150px;
                      border: 1px solid rgb(200, 200, 200);
                      padding: 5px;
                      font-size: 1em;
                  }
                  .text-centered {
                      text-align: center;
                  }
                  .text-container {
                      margin: 1em 1.5em;
                  }
                  .flex-container {
                      display: flex;
                  }
                  .flex-container.column {
                      flex-direction: column;
                  }
                  .newline-linebreak {
                      white-space: pre-line;
                  }
              </style>
              <title>ZTNA Access Denied</title>
          </head>
          <body><div class="message-container">
          <div class="logo"></div>
          <h1>Invalid ZTNA Certificate</h1>
          <table><tbody>
              <tr>
                  <td><b>Error Code:</b></td>
                  <td>001</td>
              </tr>
              <tr>
                  <td><b>Error Message:</b></td>
                  <td>The page you requested has been blocked because the ZTNA certificate is invalid.</td>
              </tr>
              <tr>
                  <td><b>Certificate Information:</b></td>
                  <td>Serial number: 03F6813097089958ECC2496BBBDD1917E0A09131.</td>
              </tr>
              <tr>
                  <td><b>Request Time:</b></td>
                  <td>1764869574; 2025-12-04 18:32:54 CET</td>
              </tr>
          </tbody></table>
      </div></body>
      </html>
      
      
      [2025-12-04 18:33:58.0015387] [fortitcs] WriteFile ztna_error.html success!
      [2025-12-04 18:33:58.0201953] [fortitcs] resp: &{200 OK 200 HTTP/1.1 1 1 map[Content-Type:[text/html; charset=utf-8] Date:[Thu, 04 Dec 2025 17:33:58 GMT]] 0xc000086740 -1 [chunked] false false map[] 0xc0000b66c0 <nil>}
      [2025-12-04 18:33:58.0203948] [fortitcs] PostAlertGui: Gui opened!
      [2025-12-04 18:33:58.0204292] [fortitcs] PostAlertGui: {"Id":"ZTNA_ALERT","Url":"http://127.0.0.1:49476"}
      [2025-12-04 18:33:58.0204795] [fortitcs] Sent: 50
      [2025-12-04 18:33:58.0204922] [fortitcs] OpenUrl: <nil>
      [2025-12-04 18:33:58.0205919] [fortitcs debug] 16 bytes data length=16
      00000000   53 52 6E 64 36 32 42 C3 B6 54 63 51 45 30 72 4E   SRnd62B..TcQE0rN
      [2025-12-04 18:33:58.0206035] [fortitcs] Sent: 16 bytes to confirm tunnel created
      [2025-12-04 18:33:58.0206129] [fortitcs] CallUpdateStatus call! mod=4 status=0 msgType=0
      [2025-12-04 18:33:58.0219855] [fortitcs] procUpdateStatus: mod=4 status=0 msgType=0 msg.Extra= ret = 1
      [2025-12-04 18:33:58.0221182] [fortitcs] Forward win-server.ad.labdomain.com:: local: 127.0.0.1:61022 -> remote: 127.0.0.1:61585proxy: 172.31.1.2:61586 -> remote: 172.31.1.1:443
      [2025-12-04 18:33:58.0228141] [fortitcs error] CopyNetBuffer error: read tcp 127.0.0.1:61022->127.0.0.1:61585: use of closed network connection

      The impact of a revoked certificate

      Revoke client certificate

      If a certificate gets revoked via EMS, three things happen:

      1. After a sync, FortiClient deletes the certificate from the user store on the endpoint.
      2. 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).
      3. 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.

      FortiClient EMS CRL

      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.