HOWTOAdvanced

FortiGate Firewall Policy Management with PowerShell

Automate FortiGate firewall policy creation, backup, and auditing using PowerShell and the FortiOS REST API. Includes bulk rule deployment, change...

Dylan H.

Network Security

February 3, 2026
7 min read
FortiGate Firewall Policy Management with PowerShell

Prerequisites

  • FortiGate 6.4+
  • PowerShell 5.1+
  • API access enabled
  • Admin credentials

Overview

Managing firewall policies manually across multiple FortiGate devices doesn't scale. This guide covers automating policy management using PowerShell and the FortiOS REST API - from bulk rule deployment to compliance auditing.

What You'll Learn

  • FortiOS REST API authentication patterns
  • Bulk policy creation and modification
  • Configuration backup automation
  • Policy compliance auditing
  • Change tracking and rollback

Why Automate Firewall Management?

Manual ApproachAutomated Approach
Error-prone rule entryTemplated, validated rules
Inconsistent across devicesStandardized policies
No change trackingGit-backed versioning
Hours per deviceMinutes for entire fleet

API Authentication

FortiGate supports both API tokens and session-based authentication. API tokens are recommended for automation.

Generate API Token

  1. Navigate to System > Administrators
  2. Create new REST API Admin
  3. Set appropriate profile (read-only or read-write)
  4. Copy the generated token

PowerShell Authentication Module

function Connect-FortiGate {
    param(
        [Parameter(Mandatory)]
        [string]$Hostname,
 
        [Parameter(Mandatory)]
        [string]$ApiToken,
 
        [int]$Port = 443,
        [switch]$SkipCertificateCheck
    )
 
    $script:FGSession = @{
        BaseUrl = "https://${Hostname}:${Port}/api/v2"
        Headers = @{
            "Authorization" = "Bearer $ApiToken"
            "Content-Type"  = "application/json"
        }
        SkipCert = $SkipCertificateCheck
    }
 
    # Test connection
    try {
        $params = @{
            Uri     = "$($script:FGSession.BaseUrl)/cmdb/system/status"
            Headers = $script:FGSession.Headers
            Method  = "GET"
        }
 
        if ($SkipCertificateCheck) {
            $params.SkipCertificateCheck = $true
        }
 
        $response = Invoke-RestMethod @params
        Write-Host "Connected to $($response.results.hostname)" -ForegroundColor Green
        Write-Host "  Version: $($response.results.version)"
        Write-Host "  Serial:  $($response.results.serial)"
 
        return $true
    }
    catch {
        Write-Error "Connection failed: $_"
        return $false
    }
}

Firewall Policy Operations

List All Policies

function Get-FGFirewallPolicy {
    param(
        [string]$Name,
        [string]$VDOM = "root"
    )
 
    $uri = "$($script:FGSession.BaseUrl)/cmdb/firewall/policy"
 
    if ($Name) {
        $uri += "/$Name"
    }
 
    $params = @{
        Uri     = $uri
        Headers = $script:FGSession.Headers
        Method  = "GET"
    }
 
    if ($script:FGSession.SkipCert) {
        $params.SkipCertificateCheck = $true
    }
 
    $response = Invoke-RestMethod @params
    return $response.results
}
 
# Usage
$policies = Get-FGFirewallPolicy
$policies | Select-Object policyid, name, srcintf, dstintf, action | Format-Table

Create New Policy

function New-FGFirewallPolicy {
    param(
        [Parameter(Mandatory)]
        [string]$Name,
 
        [Parameter(Mandatory)]
        [string]$SourceInterface,
 
        [Parameter(Mandatory)]
        [string]$DestinationInterface,
 
        [string[]]$SourceAddress = @("all"),
        [string[]]$DestinationAddress = @("all"),
        [string[]]$Service = @("ALL"),
        [ValidateSet("accept", "deny", "ipsec")]
        [string]$Action = "accept",
        [switch]$NAT,
        [switch]$LogTraffic,
        [string]$Comment
    )
 
    $policy = @{
        name     = $Name
        srcintf  = @(@{ name = $SourceInterface })
        dstintf  = @(@{ name = $DestinationInterface })
        srcaddr  = $SourceAddress | ForEach-Object { @{ name = $_ } }
        dstaddr  = $DestinationAddress | ForEach-Object { @{ name = $_ } }
        service  = $Service | ForEach-Object { @{ name = $_ } }
        action   = $Action
        nat      = if ($NAT) { "enable" } else { "disable" }
        logtraffic = if ($LogTraffic) { "all" } else { "disable" }
    }
 
    if ($Comment) {
        $policy.comments = $Comment
    }
 
    $params = @{
        Uri         = "$($script:FGSession.BaseUrl)/cmdb/firewall/policy"
        Headers     = $script:FGSession.Headers
        Method      = "POST"
        Body        = $policy | ConvertTo-Json -Depth 10
    }
 
    if ($script:FGSession.SkipCert) {
        $params.SkipCertificateCheck = $true
    }
 
    $response = Invoke-RestMethod @params
    Write-Host "Policy '$Name' created with ID: $($response.mkey)" -ForegroundColor Green
    return $response
}

Bulk Policy Deployment

Deploy policies from a CSV template:

function Import-FGPoliciesFromCsv {
    param(
        [Parameter(Mandatory)]
        [string]$CsvPath
    )
 
    $policies = Import-Csv $CsvPath
    $results = @()
 
    foreach ($policy in $policies) {
        Write-Host "Creating policy: $($policy.Name)..."
 
        try {
            $result = New-FGFirewallPolicy `
                -Name $policy.Name `
                -SourceInterface $policy.SourceInterface `
                -DestinationInterface $policy.DestinationInterface `
                -SourceAddress ($policy.SourceAddress -split ",") `
                -DestinationAddress ($policy.DestinationAddress -split ",") `
                -Service ($policy.Service -split ",") `
                -Action $policy.Action `
                -NAT:([bool]$policy.NAT) `
                -LogTraffic:([bool]$policy.LogTraffic) `
                -Comment $policy.Comment
 
            $results += [PSCustomObject]@{
                Name   = $policy.Name
                Status = "Success"
                ID     = $result.mkey
            }
        }
        catch {
            $results += [PSCustomObject]@{
                Name   = $policy.Name
                Status = "Failed"
                Error  = $_.Exception.Message
            }
        }
    }
 
    return $results
}

CSV Template:

Name,SourceInterface,DestinationInterface,SourceAddress,DestinationAddress,Service,Action,NAT,LogTraffic,Comment
Allow-Web-DMZ,internal,dmz,LAN_Servers,Web_Servers,HTTP,HTTPS,accept,false,true,Web server access
Allow-DNS-Out,internal,wan1,all,DNS_Servers,DNS,accept,true,false,Outbound DNS
Block-Telnet,any,any,all,all,TELNET,deny,false,true,Security - No Telnet

Configuration Backup

Automated Backup Script

function Backup-FGConfiguration {
    param(
        [Parameter(Mandatory)]
        [string]$OutputPath,
 
        [ValidateSet("full", "vdom")]
        [string]$Scope = "full",
 
        [switch]$IncludeTimestamp
    )
 
    $filename = "fortigate-backup"
 
    if ($IncludeTimestamp) {
        $timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
        $filename = "${filename}-${timestamp}"
    }
 
    $uri = "$($script:FGSession.BaseUrl)/monitor/system/config/backup"
    $uri += "?scope=$Scope"
 
    $params = @{
        Uri     = $uri
        Headers = $script:FGSession.Headers
        Method  = "GET"
        OutFile = Join-Path $OutputPath "${filename}.conf"
    }
 
    if ($script:FGSession.SkipCert) {
        $params.SkipCertificateCheck = $true
    }
 
    Invoke-RestMethod @params
 
    $backupFile = Join-Path $OutputPath "${filename}.conf"
    $size = (Get-Item $backupFile).Length / 1KB
 
    Write-Host "Backup saved: $backupFile ($([math]::Round($size, 2)) KB)" -ForegroundColor Green
 
    return $backupFile
}

Scheduled Backup with Git Versioning

function Invoke-FGBackupWithGit {
    param(
        [Parameter(Mandatory)]
        [string]$BackupRepo,
 
        [string]$CommitMessage
    )
 
    Push-Location $BackupRepo
 
    # Perform backup
    $backupFile = Backup-FGConfiguration -OutputPath $BackupRepo
 
    # Git operations
    git add $backupFile
 
    $changes = git status --porcelain
    if ($changes) {
        $msg = if ($CommitMessage) {
            $CommitMessage
        } else {
            "Automated backup - $(Get-Date -Format 'yyyy-MM-dd HH:mm')"
        }
 
        git commit -m $msg
        Write-Host "Changes committed to Git" -ForegroundColor Green
    }
    else {
        Write-Host "No configuration changes detected" -ForegroundColor Yellow
    }
 
    Pop-Location
}

Policy Compliance Auditing

Security Baseline Check

function Test-FGPolicyCompliance {
    param(
        [switch]$Detailed
    )
 
    $policies = Get-FGFirewallPolicy
    $findings = @()
 
    foreach ($policy in $policies) {
        # Check: Any-to-Any rules
        $srcAll = $policy.srcaddr.name -contains "all"
        $dstAll = $policy.dstaddr.name -contains "all"
        $svcAll = $policy.service.name -contains "ALL"
 
        if ($srcAll -and $dstAll -and $svcAll -and $policy.action -eq "accept") {
            $findings += [PSCustomObject]@{
                PolicyID    = $policy.policyid
                PolicyName  = $policy.name
                Finding     = "CRITICAL: Any-to-Any-All rule"
                Severity    = "Critical"
                Remediation = "Restrict source, destination, or services"
            }
        }
 
        # Check: No logging on allow rules
        if ($policy.action -eq "accept" -and $policy.logtraffic -eq "disable") {
            $findings += [PSCustomObject]@{
                PolicyID    = $policy.policyid
                PolicyName  = $policy.name
                Finding     = "WARNING: Allow rule without logging"
                Severity    = "Medium"
                Remediation = "Enable traffic logging"
            }
        }
 
        # Check: Disabled policies
        if ($policy.status -eq "disable") {
            $findings += [PSCustomObject]@{
                PolicyID    = $policy.policyid
                PolicyName  = $policy.name
                Finding     = "INFO: Disabled policy"
                Severity    = "Low"
                Remediation = "Remove or re-enable policy"
            }
        }
 
        # Check: Missing comments
        if ([string]::IsNullOrWhiteSpace($policy.comments)) {
            $findings += [PSCustomObject]@{
                PolicyID    = $policy.policyid
                PolicyName  = $policy.name
                Finding     = "INFO: No policy description"
                Severity    = "Low"
                Remediation = "Add descriptive comment"
            }
        }
    }
 
    # Summary
    Write-Host "`nCompliance Summary" -ForegroundColor Cyan
    Write-Host "==================" -ForegroundColor Cyan
    Write-Host "Total Policies: $($policies.Count)"
    Write-Host "Critical: $(($findings | Where-Object Severity -eq 'Critical').Count)" -ForegroundColor Red
    Write-Host "Medium:   $(($findings | Where-Object Severity -eq 'Medium').Count)" -ForegroundColor Yellow
    Write-Host "Low:      $(($findings | Where-Object Severity -eq 'Low').Count)" -ForegroundColor Gray
 
    if ($Detailed) {
        return $findings
    }
 
    return $findings | Where-Object { $_.Severity -in @("Critical", "Medium") }
}

VPN Configuration

Site-to-Site IPsec VPN

function New-FGIPsecVPN {
    param(
        [Parameter(Mandatory)]
        [string]$Name,
 
        [Parameter(Mandatory)]
        [string]$RemoteGateway,
 
        [Parameter(Mandatory)]
        [string]$PreSharedKey,
 
        [Parameter(Mandatory)]
        [string]$LocalSubnet,
 
        [Parameter(Mandatory)]
        [string]$RemoteSubnet,
 
        [string]$Interface = "wan1"
    )
 
    # Phase 1
    $phase1 = @{
        name          = $Name
        type          = "static"
        interface     = $Interface
        "remote-gw"   = $RemoteGateway
        psksecret     = $PreSharedKey
        proposal      = "aes256-sha256"
        dhgrp         = "14"
        nattraversal  = "enable"
    }
 
    $params = @{
        Uri     = "$($script:FGSession.BaseUrl)/cmdb/vpn.ipsec/phase1-interface"
        Headers = $script:FGSession.Headers
        Method  = "POST"
        Body    = $phase1 | ConvertTo-Json -Depth 5
    }
 
    if ($script:FGSession.SkipCert) {
        $params.SkipCertificateCheck = $true
    }
 
    Invoke-RestMethod @params
    Write-Host "Phase 1 created" -ForegroundColor Green
 
    # Phase 2
    $phase2 = @{
        name        = "${Name}_p2"
        "phase1name" = $Name
        proposal    = "aes256-sha256"
        "src-subnet" = $LocalSubnet
        "dst-subnet" = $RemoteSubnet
    }
 
    $params.Uri = "$($script:FGSession.BaseUrl)/cmdb/vpn.ipsec/phase2-interface"
    $params.Body = $phase2 | ConvertTo-Json -Depth 5
 
    Invoke-RestMethod @params
    Write-Host "Phase 2 created" -ForegroundColor Green
 
    Write-Host "VPN '$Name' configured successfully" -ForegroundColor Cyan
}

Best Practices

  1. Use API tokens - More secure than storing passwords
  2. Enable logging - All allow rules should log traffic
  3. Document policies - Every rule needs a comment
  4. Version control - Git-back your configurations
  5. Audit regularly - Run compliance checks weekly
  6. Test in lab - Never deploy untested policies to production

Troubleshooting

API Connection Issues

# Test connectivity
Test-NetConnection -ComputerName "fortigate.local" -Port 443
 
# Check API status
curl -k "https://fortigate.local/api/v2/cmdb/system/status" `
     -H "Authorization: Bearer YOUR_TOKEN"

Common Error Codes

CodeMeaning
-5Object already exists
-6Object not found
-10Permission denied
-15Invalid parameter

Next Steps