Fortinet SD-WAN Jinja Orchestrator — Part 3: PSK to Cert With FMG as CA
Where we are
Part 1 laid out the split: Jinja is the scaffold for shape-varying SD-WAN/ADVPN config, classic CLI templates fill the gaps. Part 2 read Fortinet’s sdwan-advpn-reference repo end to end and named the three Jinja patterns the templates lean on — predicate-filtered loops, ipaddr derivation, and with context imports.
This post is the payoff. We take the single-hub PSK example we read in Part 2 and customise it to certificate-based IPSec, with FortiManager itself acting as the certificate authority. The reason to walk this specific change is that it is the most common production customisation: nearly every real-world SD-WAN deployment outgrows PSK, and FMG-as-CA is the path of least resistance for getting onto certificates without standing up a separate PKI.
I want this to read as a worked example, not as a generic “how PKI works” tutorial, so we’ll move through it in operational order — what you actually do, in what sequence, and what the rendered config looks like before and after. The abstract pattern at the end (a recipe for any Orchestrator customisation, not just this one) is the bit you’ll reuse.
Why move from PSK to cert at all
The single-hub example in Part 2 ships with a literal PSK in the Project Template:
{# inside 02-Edge-Overlay.j2 #}
{% else %}
set authmethod psk
set peertype any
set psksecret {{ project.psk|default('S3cr3t!') }}
{% endif %}
S3cr3t!. Fortinet are not being shy about it — that placeholder is there to make the point that PSK in a multi-spoke ADVPN deployment is a single shared secret that every spoke holds in plaintext config. Concretely:
- Rotation is all-or-nothing. Changing the PSK means a coordinated re-key across the whole estate. Schedule it for a weekend window or accept a blackout.
- There is no per-device revocation. A spoke that walks out the door of an office and gets parted out on eBay carries the keys to your overlay with it. You have one lever — rotate the PSK — and it affects every site.
- The shared-secret model is structurally unsound for ADVPN. Hubs accept any spoke that presents the right PSK and a peer identity. There is no cryptographic binding between a spoke’s identity and the FortiGate it’s actually running on.
Certificates fix all three. Every spoke (and every hub) gets its own device certificate, signed by a CA you control. The CA’s public certificate is distributed to every device as a trust anchor, which is how each device validates the others. Per-device revocation is a CRL entry. Rotation is per-device. The “is this really site1-1?” question is answered cryptographically, not “did they read the same paragraph in the runbook”.
The price is operational complexity: certificates expire, CRLs need to be reachable, clocks need to be roughly accurate, and you need a CA. The next section is about making that price as small as possible.
FortiManager as a CA — how it actually works
FortiManager has shipped a built-in CA function for several releases. Conceptually it gives you four things:
- A local root CA on FMG itself. Generated on enable, with a configurable common name and validity window. This is what signs every device certificate. The CA’s private key never leaves FMG; the public CA certificate is what you distribute to devices as a trust anchor.
- Per-device certificate enrolment. For every managed FortiGate, FMG can generate a device certificate signed by the local CA. The certificate’s subject CN is conventionally the device’s hostname (or a stable identifier you choose). The cert + private key gets pushed to the FortiGate as a local certificate object. FMG retains the cert; the private key lives in the FortiGate’s protected keystore.
- A CRL endpoint. FMG publishes a CRL the FortiGates can fetch. When you revoke a device, the next CRL fetch on every other device picks it up.
- Renewal, on either a scheduled cadence or on-demand. You get a notification ahead of expiry; you can renew with a button click; FMG signs a new cert and FMG re-pushes it.
This is not a substitute for an enterprise PKI. If you already have a Microsoft AD CS or a HashiCorp Vault PKI doing your enterprise certificates, you can absolutely point the FortiGates at that instead — Fortinet supports SCEP enrolment against external CAs, and the IPSec config doesn’t care who signed the cert as long as the trust chain validates. But for “I have FMG, I have FortiGates, I want certs, please make it stop being hard”, FMG-as-CA is the answer.
The pattern is:
- Hub holds the FMG CA cert as a trust anchor, holds its own device cert/key, validates spoke certs by chaining them back to the FMG CA.
- Spoke holds the FMG CA cert as a trust anchor, holds its own device cert/key, validates the hub the same way.
- Both sides fetch the CRL from FMG (over HTTPS, on a configurable interval).
- IPSec phase1 uses
authmethod signature, references the local device cert by name, and references the FMG CA either aspeertype any(any cert signed by a trusted CA is acceptable) orpeertype peerwith auser peerfilter (only certs matching specific subject criteria are acceptable).
The before state
Let’s anchor on what the PSK build actually looks like, so the diff at the end is meaningful. From the repo’s pre-rendered output for the single-hub example (dynamic-bgp-on-lo/rendered/single_hub/site1-1), the ISP1 tunnel comes out as:
config vpn ipsec phase1-interface
edit "H1_ISP1"
set interface "port1"
set ike-version 2
set transport udp
set authmethod psk
set peertype any
set psksecret S3cr3t!
set keylife 28800
set localid site1-1
set exchange-fgt-device-id enable
set net-device enable
set link-cost 0
set proposal aes256gcm-prfsha256 aes256-sha256
set idle-timeout enable
set shared-idle-timeout enable
set auto-discovery-receiver enable
set add-route disable
set encapsulation none
set exchange-ip-addr4 10.200.1.1
set network-overlay disable
set remote-gw 100.64.1.1
set dpd-retrycount 3
set dpd-retryinterval 5
set dpd on-idle
unset local-gw
unset monitor
next
end
authmethod psk, peertype any, psksecret S3cr3t!. Identity is asserted by localid site1-1 — a string, with no cryptographic backing. Hold this in your head as we walk through the migration.
The customisation, in operational order
I’ll keep this concrete and stepwise. The order matters: if you flip the Project Template’s cert_auth flag before the FortiGates actually have their certs, the first install will rebuild phase1 with cert references that don’t resolve, and you’ll lose your tunnels.
Step 1 — Enable the FMG CA and generate the root
In FMG: System Settings → Certificates → Local Certificates → Local CA. Generate the root CA. Pick a meaningful common name (TheCA matches the Fortinet reference templates’ default name and saves you a Project Template change later, but you can pick anything; “MyCorp SDWAN CA” with the appropriate validity is fine).
Validity is a real decision. Twenty years is the answer most people pick because nobody wants to think about the root expiring; ten years is the answer most security teams want; five years is the answer most security teams should want and won’t get. Whatever you choose, write the expiry on a calendar, because root renewal is a separate operation from device renewal and it requires re-distributing the trust anchor to every device.
After generation, FMG holds:
- The CA’s private key (never leaves FMG).
- The CA’s public certificate (this is what you’ll distribute as the trust anchor on each FortiGate).
Step 2 — Enrol each managed FortiGate
In FMG: Device Manager → Device list → (per device) → Certificates. Generate a device certificate signed by the local CA. Convention is:
- Subject CN = device hostname (matches what’s in
DVMDB.name). - Validity = 2–5 years. Shorter than the root, long enough not to be operationally painful.
- Key usage = digital signature, key encipherment. Extended key usage = IPSec end entity / IKE intermediate.
- Subject alternative names = optional. If you’re going to do
peertype peerwith subject filtering later, decide your filter now and put the matching SAN in.
You can script this. FMG’s API supports certificate enrolment per device, so for a large estate you write a small Python loop that walks dvm/device and calls the enrol endpoint per record. For a small estate, the UI is fine.
After enrolment, every FortiGate has:
- A local certificate object — a name (say,
Edgefor spokes,Hubfor hubs, matching the Project Template defaults) and the device’s private key signed by FMG. - The FMG CA certificate, installed as a trust anchor (
config vpn certificate ca).
Step 3 — Distribute the CA cert as trust anchor
If FMG pushed the device certificate, it also pushes the CA’s public cert as a trust anchor at the same time. If you’ve done anything manual, install the CA cert on the device under Security Fabric → Certificates → CA Certificates (or via CLI: config vpn certificate ca, edit "FMG_CA", set ca <pem>, next, end). This is what makes the device able to validate certificates signed by FMG.
You also want the CRL distribution point reachable from every device. FMG publishes the CRL at a URL you configure; on each FortiGate, config vpn certificate crl references it. If your spokes can’t reach FMG directly, you’ll need an alternative distribution path (a reverse proxy, a public CRL host, etc.) — but in practice, spokes can almost always reach FMG over the same management overlay you’re already running.
Step 4 — Switch the Project Template to cert mode
The Project Template change is one line, and it’s why the Orchestrator is worth using. In Project.singlehub.generic.nocert.j2, you swap:
{% set cert_auth = false %}
…to:
{% set cert_auth = true %}
That’s the only edit to the Project Template. Better — rename the file to Project.singlehub.generic.cert.j2 so its name documents what changed, and you’re done. The reference repo ships a separate Project.dualreg.cert.j2 precisely as a pre-made example of the same flip in a more complex topology.
You can also set two related project-wide flags if you want stricter cert validation:
{% set cert_auth_filter = true %} {# turn on peer-cert filtering #}
{% set edge_cert_filter = 'TheCA' %} {# name of the user peer object #}
This switches the rendered phase1 from set peertype any to set peertype peer with set peer "TheCA", which means the hub will only accept spoke certs that match a config user peer rule named TheCA — typically a CN or issuer subject filter. We’ll touch on the user peer block in a minute.
Step 5 — Reference cert names in inventory or template
Whether you need an inventory change at all depends on whether your cert naming is uniform or per-device.
Uniform. All edges use a cert object literally named Edge, all hubs use Hub. In this case the Project Template’s defaults work and the inventory doesn’t need to change at all — 02-Edge-Overlay.j2 falls back to project.edge_cert_template|default('Edge').
Per-device. Some edges have cert object names that include their hostname (Edge_site1-1) or some other discriminator. In this case you’d either add a Project Template-level helper variable that derives the name from hostname ({% set edge_cert = 'Edge_' + hostname %}) or you’d inject the cert name per device via inventory.
I recommend the uniform approach unless you have a compliance reason to do otherwise. Per-device cert names are pointless visual variation; the actual per-device identity is in the cert itself (the subject CN) and FMG already tracks which cert belongs to which device. Don’t make your inventory richer than it needs to be.
Step 6 — Render and inspect
If you’re using offline render to validate:
./render_config.py -p dynamic-bgp-on-lo/projects/Project.singlehub.generic.cert.j2 \
-i dynamic-bgp-on-lo/projects/inventory.singlehub.generic.json
If you’re pushing via FMG: import the modified template, attach to model devices, use Install Preview to look at the diff against the current device config. Either way, the rendered IPSec phase1 changes from PSK to signature.
The after state
The same H1_ISP1 tunnel on site1-1, rendered with cert_auth = true and cert_auth_filter = false:
config vpn ipsec phase1-interface
edit "H1_ISP1"
set interface "port1"
set ike-version 2
set transport udp
set authmethod signature
set certificate "Edge"
set peertype any
set keylife 28800
set localid site1-1
set exchange-fgt-device-id enable
set net-device enable
set link-cost 0
set proposal aes256gcm-prfsha256 aes256-sha256
set idle-timeout enable
set shared-idle-timeout enable
set auto-discovery-receiver enable
set add-route disable
set encapsulation none
set exchange-ip-addr4 10.200.1.1
set network-overlay disable
set remote-gw 100.64.1.1
set dpd-retrycount 3
set dpd-retryinterval 5
set dpd on-idle
unset local-gw
unset monitor
next
end
Two lines change. set authmethod flips from psk to signature. set psksecret … is gone, replaced by set certificate "Edge" which references the local certificate object installed on the FortiGate. Everything else is identical — the same interface, the same tunnel name, the same local-id, the same exchange parameters, the same DPD, the same remote-gw, the same proposal.
Crucially: no SD-WAN config changes. No BGP config changes. No routing changes. No firewall changes. The Jinja conditional in 02-Edge-Overlay.j2 is intentionally narrow:
{% if project.cert_auth|default(true) %}
set authmethod signature
set certificate "{{ project.edge_cert_template|default('Edge') }}"
set peertype {{ 'peer' if project.cert_auth_filter|default(false) else 'any' }}
{% if project.cert_auth_filter|default(false) %}
set peer {{ project.edge_cert_filter|default('TheCA') }}
{% endif %}
{% else %}
set authmethod psk
set peertype any
set psksecret {{ project.psk|default('S3cr3t!') }}
{% endif %}
That’s the whole behavioural difference of the migration, expressed inside the template. The rest of the SD-WAN overlay shape — tunnel names, interface mappings, BGP peerings, advertised prefixes — is invariant under the cert/PSK choice. That property is exactly the thing the Orchestrator’s scaffold gives you, and it’s why customising it is a per-flag exercise rather than a fork-and-rewrite exercise.
If cert_auth_filter is also true, the rendered phase1 additionally gains:
set peertype peer
set peer "TheCA"
— but peer "TheCA" references a config user peer object that isn’t in the Jinja Orchestrator’s scope. The Orchestrator deliberately doesn’t render the user peer / user peergrp blocks. This is the most natural place in the whole migration for a small classic CLI template to do its job, which brings us to —
Where the CLI template fills the gap
The certificate-related blocks that aren’t part of the Jinja Orchestrator’s templates, but that need to exist on every device for the cert-mode IPSec phase1 to validate:
config vpn certificate ca
edit "FMG_CA"
set ca "-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----"
next
end
config vpn certificate crl
edit "FMG_CRL"
set http-url "https://fmg.internal/cert/crl/FMG_CA"
set update-interval 3600
next
end
config user peer
edit "TheCA"
set ca "FMG_CA"
next
end
config user peergrp
edit "TheCA"
set member "TheCA"
next
end
Four blocks. None of them vary in shape between devices in the same project — every spoke gets the same CA trust anchor, every spoke fetches the same CRL, every spoke validates the same peer rule. This is exactly the shape-invariant pattern from Part 1: write it once in a classic CLI template, assign it to the device group, leave it alone.
The right place for this in a FortiManager deployment is a small classic CLI template named something like Cert-Trust-Anchor, assigned to every device in the cert-mode ADOM. It pairs with the Jinja Orchestrator the same way header/footer templates pair with policy packages — Jinja makes the variable-shape phase1, the CLI template makes the invariant trust framework, and the device’s running config is the merge of both.
If you push the CA cert PEM through the template (rather than relying on FMG’s per-device cert enrolment to deliver it), put the PEM in a meta variable rather than literally in the template body. It keeps the template terse and lets you rotate the CA without editing the template — change the meta variable value and re-install.
Gotchas
Things that bite people during this migration. Mentioning them now is cheaper than learning them on a Saturday.
Clock skew. Certificates have validity windows. If a spoke’s clock is wrong by enough — and devices that have been off the network for months will drift — its cert appears not-yet-valid or expired even though the actual cert is fine. NTP on every device, redundant NTP sources, monitor it. Recovery of a clock-skewed FortiGate is a console job, not an overlay one.
CRL fetch interval vs revocation latency. The CRL update interval (set update-interval 3600 in the example) is also your revocation reaction window. If you revoke a stolen spoke now, other devices honour the revocation when they next fetch the CRL. An hour is fine for most environments; if your threat model wants sub-minute, you want OCSP, which is a different config.
Expired certs and the chicken-and-egg recovery. A spoke whose device cert has expired cannot establish IPSec, which means it cannot reach FMG over the overlay, which means it cannot get a new cert pushed. Out-of-band is the answer: a parallel management path (a static IPSec from the underlay direct to a management host, an SSH-over-public path with strict ACLs, or a serial-console runbook). Build that path before you need it. If you wait until a remote site’s cert expires to design the recovery, you’ve already lost.
Hub-side peergrp membership when filter mode is on. If you turn on cert_auth_filter = true, every hub needs to know about the user peer group referenced in the phase1. If you forget to push the matching config user peer and config user peergrp blocks to the hubs, IKE-AUTH fails with a “cert not trusted” log entry that does not, sadly, tell you the actual problem. The CLI template that does the trust anchor on the spokes should do it on the hubs too.
The Project Template’s default cert names. edge_cert_template defaults to Edge, hub_cert_template defaults to Hub. If you’ve adopted a different naming convention in FMG’s cert enrolment, override the defaults in the Project Template or you’ll get phase1s pointing at certificate objects that don’t exist on the device, with predictably hostile error logs.
Mixed PSK/cert during cutover. There’s a non-trivial temptation to flip half the estate to cert and leave half on PSK during a phased rollout. The Orchestrator’s cert_auth flag is project-wide; it doesn’t switch per device. If you really need a phased rollout, the clean approach is two parallel project files (Project.psk.j2 and Project.cert.j2), each driven by its own inventory subset, with the spoke moving from one to the other on cutover. Trying to interleave is more pain than it’s worth.
The abstracted recipe
Stand back from the specific PSK-to-cert migration for a moment. The methodology generalises to any Orchestrator customisation, and it’s worth naming the steps so the next change is faster:
- Start from the closest reference project. The four shipped in
dynamic-bgp-on-lo/projects/are your starting points. Don’t customise from a blank file; customise from the example that’s structurally nearest your topology. - Render offline first against a tiny inventory. Two spokes, one hub, one region. Look at the rendered text. Get a feel for what the templates produce before you change anything.
- Identify the deltas explicitly. What does your environment need that the reference doesn’t have? Make a list. For each delta, decide which of three categories it falls into.
- Categorise each delta.
- Inventory delta. A variable changes per device or per project. Add the variable to inventory; the templates already consume it. (Example: per-device WAN counts, per-hub overlay IPs.)
- Project Template delta. A structural choice changes project-wide. Edit the Project Template’s top-level toggles or maps. (Example: cert vs PSK, single-region vs multi-region, RR vs RR-less.)
- Device-type template delta. A behaviour changes for a role of device. This is the only case where you fork a
0X-*.j2file. It’s the most expensive option and the easiest to regret; treat it as a last resort.
- Anything outside Jinja’s scope goes in a classic CLI template. Trust anchors, system config, monitoring agents, compliance hot-fixes, anything shape-invariant. They sit alongside Jinja, not inside it.
- Render offline, diff against the prior render, install via FMG with Install Preview. Three controls between intent and production: the inventory diff, the rendered-output diff, the install preview. Use all three.
- Write the change down somewhere a colleague can find it. Whether that’s a wiki, a commit message on a forked template repo, or a runbook entry. The Jinja Orchestrator is legible because Fortinet wrote it down. Your fork deserves the same treatment.
Closing
The Jinja Orchestrator is not a product. It’s a body of distilled experience that Fortinet are willing to ship in source form so you can read it, run it, and make it your own. The temptation when you first see it is to treat it as either a black box (don’t touch, it must be magic) or a starting point you immediately fork into something unrecognisable. Both miss the actual value.
The actual value is that ninety percent of what you need for an SD-WAN/ADVPN deployment is already there, expressed cleanly, in a way that supports the variable shape your real estate has. The remaining ten percent is what you bring — a CA decision, a non-standard underlay, a multi-VRF segmentation policy, a security policy that lives in FMG policy packages instead of the templates. Recognise the boundary, customise inside it, and let classic CLI templates carry the bits that don’t belong in Jinja at all.
If you found a wrinkle this series didn’t cover — an underlay shape the templates don’t handle cleanly, a customisation pattern that worked better than the recipe above, a place where the offline/FMG distinction caught you out — push back, and I’ll fold it into a follow-up.
For broader context on where FortiManager fits in a lab and how to put one in front of your spokes for these kinds of experiments, the Building a FortiManager Lab on Proxmox series walks the prerequisites end to end.