Modern enterprise VPN security requires more than just a username and password. A stronger design combines an encrypted VPN tunnel, protected EAP authentication, centralized identity validation, and a second authentication factor.
This guide shows how to build a FortiGate IPsec dial-up VPN with FreeRADIUS using EAP-TTLS authentication, WSO2 Identity Platform (Asgardeo) for password validation, and Google Authenticator TOTP for multi-factor authentication (MFA). The FreeRADIUS server runs on Ubuntu 24.04 LTS.
What You Will Build
By the end of the implementation, the authentication path will look like this:
- FortiClient connects to FortiGate using IKEv2/IPsec.
- FortiGate uses EAP authentication and RADIUS.
- FreeRADIUS terminates EAP-TTLS and handles the protected inner PAP request.
- The policy engine separates the user’s password from the six-digit TOTP.
- Asgardeo validates the user’s password and supplies group information.
- Google Authenticator provides the user’s TOTP factor.
- FreeRADIUS returns a Fortinet group attribute to FortiGate.
- FortiGate firewall policies determine which internal resources the VPN user can access.
FortiGate IPsec Dial-Up VPN with FreeRADIUS – Architecture Overview

Why Use EAP-TTLS for a FortiGate IKEv2 VPN?
EAP-TTLS creates a TLS-protected tunnel for the inner authentication exchange. This is useful when the inner method needs to carry PAP credentials but those credentials should not be exposed on the network.
EAP-TTLS Inner and Outer Layers
The outer exchange establishes the TLS-protected EAP-TTLS tunnel. The inner exchange then carries the actual authentication data.
In this implementation:
- The outer identity can be
anonymous. - The FreeRADIUS server presents its TLS certificate.
- The inner method is PAP.
- The real username and
password+TOTPvalue are processed inside the TLS tunnel.
This also explains why FreeRADIUS logs can show both an outer anonymous session and an inner authenticated username.
Prerequisites
- Ubuntu 24.04 LTS VM with static IP
- FortiGate firewall with IKEv2 dial-up IPSec VPN
- Asgardeo account with a Standard-Based Application (ROPC grant enabled)
- FortiClient installed on end-user laptop
Install FreeRADIUS and Required Packages
Install FreeRADIUS, its utilities, Python integration, PAM, TOTP tooling and EAP testing tools:
apt-get update
apt-get install -y \
freeradius freeradius-utils freeradius-python3 \
python3-requests python3-yaml \
libpam-google-authenticator qrencode oathtool \
eapoltest
The main packages have these roles:
freeradiusandfreeradius-utils— RADIUS daemon and testing tools.freeradius-python3— Python policy integration.python3-requests— HTTPS calls to Asgardeo.python3-yaml— group mapping configuration.libpam-google-authenticator— Google Authenticator PAM integration.qrencode— TOTP enrollment QR codes.eapoltest— end-to-end EAP testing.
Enable the service:
systemctl enable --now freeradius
Important FreeRADIUS ownership rule:
The packaged service runs as the freerad user. If you edit files under /etc/freeradius/3.0/ with a root-owned editor, restore the ownership before restarting:
chown freerad:freerad /etc/freeradius/3.0/<modified-file>
Perform a Baseline FreeRADIUS Test (optional)
Before adding EAP-TTLS and the custom policy engine, verify that the basic RADIUS installation works.
#Create a temporary test user:
cat >> /etc/freeradius/3.0/mods-config/files/authorize << 'EOF'
# Temporary sanity test user
sanity_test_user Cleartext-Password := "sanitytest123"
EOF
chown freerad:freerad /etc/freeradius/3.0/mods-config/files/authorize
systemctl restart freeradius
#Test a valid password:
radtest sanity_test_user sanitytest123 127.0.0.1 0 testing123 # expect Access-Accept
#Then test an invalid password:
radtest sanity_test_user wrongpassword 127.0.0.1 0 testing123 # expect Access-Reject
#The first test should return Access-Accept and the second should return Access-Reject.
#Remove the temporary account after testing.
sudo sed -i '/^# Temporary sanity test user$/,+1d' /etc/freeradius/3.0/mods-config/files/authorize

Configure FortiGate as a RADIUS Client
The FortiGate must be defined as a RADIUS client in FreeRADIUS. In clients.conf, configure a dedicated client entry similar to:
vim /etc/freeradius/3.0/clients.conf
client fortigate_vpn {
ipaddr = <FORTIGATE_RADIUS_SOURCE_IP>
secret = <RADIUS_SHARED_SECRET>
shortname = fortigate-vpn
nas_type = other
# Confirmed via radius.log that this FortiGate already sends
# Message-Authenticator on every packet (BlastRADIUS / CVE-2024-3596
# mitigation) - making it explicit and required, rather than relying on
# FreeRADIUS's runtime auto-detection (which doesn't persist across
# restarts and re-logs the same notice every time).
require_message_authenticator = true
}
Make sure to validate the configuration and restart the service after change:
#Validate:
freeradius -CX
#Then, Restart the service:
systemctl restart freeradius
Build the Internal PKI for EAP-TTLS
EAP-TTLS requires the RADIUS server to present a TLS certificate. FreeRADIUS ships with a bootstrap script that generates a self-signed CA and server certificate:
cd /etc/freeradius/3.0/certs
#Modify the ca.cnf and server.cnf files accordingly
#ca.cnf
#-------
[ CA_default ]
default_days = 3650
[certificate_authority]
countryName = LK
stateOrProvinceName = Western
localityName = Colombo
organizationName = Demo
emailAddress = [email protected]
commonName = "Demo VPN RADIUS CA"
#server.cnf
#----------
[ CA_default ]
default_days = 3650
[server]
countryName = LK
stateOrProvinceName = Western
localityName = Colombo
organizationName = Demo
emailAddress = [email protected]
commonName = "Demo Radius Server Certificate"
Note: Replace the above with your real org details.
Then generate the certificates and change the permissions accordingly.
bash bootstrap
chown freerad:freerad ca.pem server.pem server.key
chmod 640 ca.pem server.pem server.key

Note: client.crt will fail to generate (it uses client.cnf, which still has the unmodified template DN, mismatching what you just set in ca.cnf) – this is expected and fine, this design doesn’t use client certificates (inner auth is PAP, not EAP-TLS).
Once the CA an server cert is generated, you can verify it is by following commands:
openssl x509 -in ca.pem -noout -subject -dates
openssl x509 -in server.crt -noout -subject -issuer -dates
openssl verify -CAfile ca.pem server.pem # must print "server.pem: OK"
Note: Every FortiClient endpoint participating in EAP-TTLS must trust the CA that signed the FreeRADIUS certificate.
Configure FreeRADIUS EAP-TTLS
Modify the following configurations of eap module on /etc/freeradius/3.0/mods-available/eap.
eap {
default_eap_type = ttls
tls-config tls-common {
private_key_file = ${certdir}/server.key
certificate_file = ${certdir}/server.pem
ca_file = ${cadir}/ca.pem
tls_min_version = "1.2"
tls_max_version = "1.2"
}
ttls {
default_eap_type = pap
copy_request_to_tunnel = yes
}
}
Make sure to validate the configuration and restart the service after change:
#Validate:
freeradius -CX
#Then, Restart the service:
systemctl restart freeradius
Test EAP-TTLS end-to-end with eapol_test
cat >> /etc/freeradius/3.0/mods-config/files/authorize << 'EOF'
# --- throwaway EAP-TTLS/PAP test user, remove after use ---
eapttls_test_user Cleartext-Password := "eapttlstest123"
EOF
chown freerad:freerad /etc/freeradius/3.0/mods-config/files/authorize
systemctl restart freeradius
wget -O /tmp/eapttls_test.conf https://raw.githubusercontent.com/lakindu93/fortigate-ikev2-freeradius-eap-ttls-mfa/refs/heads/main/scripts/eapttls_test.conf
eapol_test -c /tmp/eapttls_test.conf -a 127.0.0.1 -p 1812 -s testing123 2>&1 | tail -15

Clean up the testing:
rm -f /tmp/eapttls_test.conf
sed -i '/--- throwaway EAP-TTLS\/PAP test user/,/eapttls_test_user\tCleartext-Password := "eapttlstest123"/d' /etc/freeradius/3.0/mods-config/files/authorize
chown freerad:freerad /etc/freeradius/3.0/mods-config/files/authorize
systemctl restart freeradius
Implement the Python Policy Engine
The main policy engine – radius_policy.py
The FreeRADIUS policy engine rlm_python3 performs these major functions:
- Split the combined password and six-digit OTP.
- Validate the password against Asgardeo using the configured OAuth flow.
- Retrieve the user’s group membership.
- Validate the TOTP for cloud users.
- Map the user’s group to
Fortinet-Group-Name. - Return
Access-AcceptorAccess-Reject.
This separation makes the authentication logic easier to maintain than embedding a large amount of logic directly into FreeRADIUS configuration.
mkdir -p /etc/freeradius/3.0/mods-config/python3/otp_secrets
mkdir -p /etc/freeradius/3.0/mods-config/python3/otp_state
You can directly download the script inside my repo.
sudo wget -O /etc/freeradius/3.0/mods-config/python3/radius_policy.py https://github.com/lakindu93/fortigate-ikev2-freeradius-eap-ttls-mfa/raw/refs/heads/main/freeradius-config/mods-config/python3/radius_policy.py
Google Authenticator TOTP validation
This code snippet is a TOTP validation module for FreeRADIUS, written in Python. Its main purpose is to validate a user’s 6-digit time-based one-time password (TOTP), such as a code generated by Google Authenticator, while also providing replay protection and brute-force protection.
Username + OTP → find user’s secret → generate valid TOTP codes → compare → prevent replay → rate-limit failures
You can directly download the script inside my repo.
sudo wget -O /etc/freeradius/3.0/mods-config/python3/otp_check.py https://raw.githubusercontent.com/lakindu93/fortigate-ikev2-freeradius-eap-ttls-mfa/refs/heads/main/freeradius-config/mods-config/python3/otp_check.py
Map Asgardeo Groups to FortiGate VPN Groups
The policy engine uses a YAML mapping file rather than hard-coding every group into Python.
vim /etc/freeradius/3.0/mods-config/python3/vpn_group_map.yaml
# Left-hand side must exactly match:
# - the Asgardeo group's display name, for cloud-path users, OR
# Right-hand side must exactly match the "group-name" configured in the FortiGate's `config user group`
network-admins: admin-users
all-non-admins: non-admin-users
Fix ownership/permissions on everything just created
chown -R freerad:freerad /etc/freeradius/3.0/mods-config/python3/
chmod 640 /etc/freeradius/3.0/mods-config/python3/*.py /etc/freeradius/3.0/mods-config/python3/*.yaml
chmod 700 /etc/freeradius/3.0/mods-config/python3/otp_secrets /etc/freeradius/3.0/mods-config/python3/otp_state
Wire the Policy Engine into FreeRADIUS
Configure and enable the python3 module
FreeRADIUS Python3 module configuration (/etc/freeradius/3.0/mods-available/python3) so that FreeRADIUS loads your custom Python module (radius_policy.py)
vim /etc/freeradius/3.0/mods-available/python3
python3 {
# Path to the python modules
python_path="${modconfdir}/${.:name}"
# radius_policy.py — Asgardeo + MFA policy engine
module = radius_policy
mod_instantiate = ${.module}
func_instantiate = instantiate
mod_authorize = ${.module}
func_authorize = authorize
}
chown freerad:freerad /etc/freeradius/3.0/mods-available/python3
ln -sf ../mods-available/python3 /etc/freeradius/3.0/mods-enabled/python3
vim /etc/freeradius/3.0/sites-available/inner-tunnel
#Inside the authorize { ... } block, scroll towards the end until you find something "pap" and below that add the "python3".
authorize {
pap
python3
}
Enable inner-tunnel reply propagation to the outer
This setting enables propagation of RADIUS reply attributes from the inner tunnel to the outer session. It is commonly required for tunneled EAP methods such as EAP-TTLS or PEAP.
When your custom Python authentication module (radius_policy.py) returns reply attributes (for example, Reply-Message, Filter-Id, or other RADIUS attributes), enabling this block ensures those attributes are copied from the inner authentication session to the outer RADIUS response sent back to the VPN client or Network Access Server (NAS), such as a FortiGate firewall. Without this change, those reply attributes would remain inside the inner tunnel and would not be visible to the client or NAS.
post-auth {
if (1) {
...
update {
&outer.session-state: += &reply:
}
}
}
chown freerad:freerad /etc/freeradius/3.0/sites-available/inner-tunnel
freeradius -CX 2>&1 | tail -5
systemctl restart freeradius
Asgardeo Setup
Create the VPN Users and Groups
Sign in to the Asgardeo console and create at least two test users, and next create the groups that you want to use for VPN authorization. In this example, I will create two groups network-admins and all-non-admins.

I’m adding one test user to each group. This allows you to verify that group-based VPN authorization is working as expected and confirm that users in different groups receive the appropriate level of access.

Create the Asgardeo Application
Go to: Applications → New Application Create a Standard-Based Application using OAuth 2.0 / OpenID Connect.

Give the name as vpn-radius-auth-demo or any name you preferred.
In the protocol section you can see the Client ID and Client Secret please make a note of it and you will require in in the next step. Also in the same section under “Allowed grant types” check the “Password” option.

In the “User Attribute” section select the groups check box under the “User Attribute Selection”.

Finally navigate to the “Info” section of the application and get the required Server Endpoints for the next step.

Configure Asgardeo Authentication for FreeRADIUS
The policy engine uses the Asgardeo OAuth endpoint to validate the user’s credentials.
The exact tenant-specific values belong in the local configuration file: /etc/freeradius/3.0/mods-config/python3/asgardeo_config.json. Protect this file because it contains sensitive application credentials.
{
"token_endpoint": "https://api.asgardeo.io/t/<ORG>/oauth2/token",
"ropc_client_id": "<CLIENT_ID>",
"ropc_client_secret": "<CLIENT_SECRET>",
"ropc_scope": "openid groups",
"groups_claim": "groups",
"http_timeout_seconds": 3,
"http_retries": 1,
"userinfo_endpoint": "https://api.asgardeo.io/t/<ORG>/oauth2/userinfo"
}
chown -R freerad:freerad /etc/freeradius/3.0/mods-config/python3/
chmod 600 /etc/freeradius/3.0/mods-config/python3/asgardeo_config.json
Configure the FreeRADIUS systemd
The packaged FreeRADIUS service makes the configuration tree read-only. However, the policy engine needs controlled write access for Per-user TOTP replay/rate-limit state
So that, Create a service override:
mkdir -p /etc/systemd/system/freeradius.service.d
#Then configure the required writable directories:
cat > /etc/systemd/system/freeradius.service.d/override.conf << 'EOF'
# The packaged unit sets ReadOnlyDirectories=/etc/freeradius/, which bind-
# mounts the entire config tree read-only in radiusd's mount namespace.
# Carve out write access for the two directories that must be writable by
# the running service - see this guide's section on the systemd.
[Service]
ReadWriteDirectories=/etc/freeradius/3.0/mods-config/python3/otp_state
EOF
Reload systemd and Verify.
systemctl daemon-reload
systemctl show freeradius -p ReadOnlyPaths -p ReadWritePaths # confirm both new paths appear in ReadWritePaths
systemctl restart freeradius
Without the override, authentication can appear to work during direct testing but fail through the real systemd-managed service because the process cannot update the required state files.
Enroll a Cloud VPN User for TOTP
This script generates a unique OTP secret for each user, saves it in the FreeRADIUS otp_secrets directory, and displays the corresponding QR code.
wget -O enroll_totp_user.sh https://github.com/lakindu93/fortigate-ikev2-freeradius-eap-ttls-mfa/raw/refs/heads/main/scripts/enroll_totp_user.sh
sudo chmod +x enroll_totp_user.sh
sudo ./enroll_totp_user.sh
Scan the printed QR code with Google Authenticator (or enter the otpauth:// URI manually). The secret is written to otp_secrets/<sanitized-username>.secret – note that @ and other special characters in the username get replaced with _ in the filename.
Note: If you are try out this existing configurations on the FreeRADIUS server with @example.com domain, you have to comment out the following section from the /etc/freeradius/3.0/proxy.conf.
# Disabled: this was the FreeRADIUS stock template's sample realm,
# left uncommented. Any User-Name ending in "@example.com" was being
# proxied to "my_auth_failover" instead of being authenticated locally
# via EAP-TTLS, which broke VPN logins for accounts using that domain.
#realm example.com {
# auth_pool = my_auth_failover
# # nostrip
#}
Test EAP-TTLS Before Testing the VPN
Using the testing script below, you can test the EAP-TTLS authentication and obtain detailed debug logs showing whether the authentication was successful or failed.
wget -O eapttls-test-mfa.sh https://github.com/lakindu93/fortigate-ikev2-freeradius-eap-ttls-mfa/raw/refs/heads/main/scripts/eapttls-test-mfa.sh
sudo chmod +x eapttls-test-mfa.sh
sudo ./eapttls-test-mfa.sh
If the authentication is success at the end of the logs you can see it as follows.

Configure FortiGate IPsec Dial-Up VPN with FreeRADIUS
Create the RADIUS Server and User Groups
Go to User & Authentication → RADIUS Servers → Create New

CLI example:
config user radius
edit "demo-freeradius-vpn"
set server "<RADIUS_SERVER_IP>"
set secret <RADIUS_SHARED_SECRET>
set auth-type auto
next
end
Note: The important point is that the FortiGate must be configured as a RADIUS client on the FreeRADIUS side.
Create the appropriate FortiGate user groups and map them to the group names returned by the RADIUS server. These groups will be used by the IPsec VPN to authenticate and authorize users through RADIUS.
Go to User & Authentication → User Groups → Create New

CLI example:
config user group
edit "grp-vpn-admins"
set member "demo-freeradius-vpn"
config match
edit 1
set server-name "demo-freeradius-vpn"
set group-name "admin-users"
next
end
next
edit "grp-vpn-users"
set member "demo-freeradius-vpn"
config match
edit 1
set server-name "demo-freeradius-vpn"
set group-name "non-admin-users"
next
end
next
end
Configure the FortiGate IKEv2 Dial-Up VPN
Go to VPN → IPsec Tunnels and then give name for the VPN and select Custom option. Then, configure the Network, Authentication, Phase 1 Proposal, and Phase 2 Selectors settings according to the following screenshots.
Before configuring the tunnel, you should first determine the VPN client address range and decide whether split tunneling will be enabled. This decision determines which traffic will be routed through the VPN tunnel when users connect remotely. Based on the selected configuration, create the relevant address objects and define the appropriate traffic selectors.
Split Tunnel vs. Full Tunnel

In general: split tunneling reduces VPN bandwidth consumption and latency for internet traffic, while full tunneling provides greater centralized control and allows the organization’s security policies and inspection mechanisms to be applied to internet-bound traffic.
IPsec Dial-Up VPN – Network Configurations

IPsec Dial-Up VPN – Authentication & Phase 1 Configurations

IPsec Dial-Up VPN – Phase 2 Configurations

Note: In some older versions of FortiOS, certain options may not be available or configurable through the GUI. For example, enabling EAP on the IPsec tunnel may require CLI configuration. In such cases, the required settings must be configured manually using the FortiGate CLI.
CLI example:
config vpn ipsec phase1-interface
edit "demo-ipsec-vpn"
set type dynamic
set interface "port1"
set ike-version 2
set peertype any
set net-device disable
set mode-cfg enable
set ipv4-dns-server1 192.168.1.1
set proposal aes256-sha256 aes256-sha512
set dpd on-idle
set dhgrp 21 20
set eap enable
set eap-identity send-request
set assign-ip-from name
set ipv4-netmask 255.255.255.0
set ipv4-split-include "demo-ipsec-client-vpn-split-tunnel"
set ipv4-name "demo-ipsec-vpn-client-range"
set psksecret ENC zHax4kIQuCOm0acIgibQ4GfY0WfjrxMypfvGyPRDl1G
set dpd-retryinterval 60
next
end
Authentication via Policy Configuration
If no user group is explicitly specified in the VPN configuration above, IPsec authentication checks the user groups associated with firewall policies that use the IPsec tunnel interface as the source. It then attempts to authenticate the user against the applicable groups.
For IKEv1, this behavior is achieved by setting XAuth to Inherit from Policy. For IKEv2, the same behavior can be configured through the CLI by enabling EAP and setting eap-identity to send-request, while leaving the authusrgrp value unset.
For more information, refer to Fortinet’s technical guide: Technical Tip: A guide to dial-up IPSec VPN authentication and policy matching
Next, let’s create the required firewall policies and associate them with the user groups created earlier.

Configure the Forticlient Endpoint for Real Testing on MacOS
Install CA Certificate on MacOS
Before configuring the FortiClient you have to install the FreeRADIUS server certificate’s CA on each endpoint who wants to connect to the VPN.
On the Mac Applications → Utilities → Keychain Access. Select the System keychain In the left-hand panel then select Certificates.
You can now import the CA certificate by either:
- Dragging the
.crt/.cerfile into the Keychain Access window, or - Right-clicking the System keychain and selecting Import Items…
You may be prompted for an administrator password.
Double-click the imported CA certificate. Expand Trust You should see “When using this certificate” : “Use System Defaults”. Change it to “Always Trust“. Then close the certificate window.

MacOS will request administrator authentication to save the trust change. After authentication, the certificate should show as trusted.

Configure the FortiClient on MacOS
FortiClient 7.4.3+ supports creating an IPsec VPN connection from Remote Access → Configure VPN, with IKEv2, EAP-TTLS/PAP authentication, Phase 1/Phase 2 proposals, and Mode Config.
Configure the basic VPN settings according to the follwoing screenshot.

Phase 1 and Phase 2 settings must correspond exactly with the configuration on the FortiGate. Fortinet notes that at least one Phase 1 and Phase 2 proposal must match the remote peer.

Finally Save the configured settings and click connect. Then you should be prompted for the RADIUS credentials if you selected “Prompt on login” during the forticlient configuration.
Verify from the FortiGate
While connecting from the FortiClient, run following commands on the Fortigate Firewall.
diagnose debug reset
diagnose debug console timestamp enable
diagnose debug application ike -1
diagnose debug application fnbamd -1
diagnose debug enable
Then initiate the VPN connection. You should ideally see the IKE negotiation followed by EAP/RADIUS authentication and finally establishment of the IPsec tunnel.
After testing, you can disable the debug on firewall.
diagnose debug disable
diagnose debug reset
Furthermore you can verify it on the Fortigate GUI as well.


Verify on FreeRADIUS
At the same time, stop the systemd freeradius.service and run it as debug mode you should see the request arriving from the FortiGate.
systemctl stop freeradius.service
freeradius -X
Conclusion
A secure FortiGate IPsec dial-up VPN with FreeRADIUS does not have to rely on a simple username-and-password authentication model.
By combining FortiGate IKEv2, FreeRADIUS EAP-TTLS, Asgardeo, and Google Authenticator TOTP, you can build a layered authentication architecture that provides protected credential transport, MFA, and group-based authorization. This FortiGate IPsec dial-up VPN with FreeRADIUS approach provides a practical way to strengthen remote VPN access while using Asgardeo for centralized identity validation and TOTP for an additional layer of security.
This setup has been successfully tested with both macOS and Windows clients. However, Windows clients require some additional configuration to enable EAP-TTLS/PAP for IPsec IKEv2 connections. For the required Windows-side configuration, refer to Fortinet’s technical documentation: How to enable EAP-TTLS for IPsec IKEv2 tunnels in VPN-only (unlicensed) FortiClient
Overall, this architecture demonstrates how FortiGate IPsec dial-up VPN with FreeRADIUS can be combined with EAP-TTLS, Asgardeo, and TOTP-based MFA to provide secure, flexible, and centralized remote-access authentication without relying solely on traditional password-based VPN access.
