Beyond PSK: PKI for Fortinet SD-WAN IPsec, Part 2 — Standing Up the PKI
Where we are
Part 1 made the architecture call: a dedicated external CA — root and issuing tier properly separated, fronted by an EST endpoint — beats FortiManager-as-CA for an enterprise-grade deployment, on exactly four grounds: EST’s pull-based renewal, OCSP’s revocation latency, real multi-tenant separation, and the fact that one PKI can serve more than one product family. We also drew the line on certificate lifetime philosophy: long-lived offline root, medium-lived issuing CA, short-lived auto-renewing leaf certificates.
This post builds that thing. By the end of it you’ll have a CA hierarchy that can issue IPsec end-entity certificates on demand, an EST endpoint a FortiGate can call out to, revocation infrastructure placed where the chicken-and-egg overlay problem can’t reach it, and — the part that actually involves a FortiGate — FortiManager redrawn into the role it’s suited for: pushing the configuration that points devices at all of the above.
I’ll work through this with HashiCorp Vault’s PKI secrets engine as the worked example, because its API-first design matches the automation theme of this series and the commands translate directly into the kind of script you’d actually run against a real estate. If your shop standardizes on Microsoft AD CS instead, every step has a direct equivalent — offline root, issuing CA joined to the domain, certificate templates instead of Vault roles, NDES (Network Device Enrollment Service) plus an EST proxy in front of it instead of a native EST listener — and I’ll call out the AD CS-shaped version of each step as we go.
Phase 1, Step 1 — the offline root
The root is the one certificate in this entire hierarchy that should be boring — generated once, used to sign exactly one or two issuing CAs, and then taken out of daily reach entirely.
$ vault secrets enable -path=pki_root pki
$ vault secrets tune -max-lease-ttl=87600h pki_root # ~10 years
$ vault write -field=certificate pki_root/root/generate/internal \
common_name="Garner Networks Root CA" \
issuer_name="root-2026" \
key_type="ec" key_bits="256" \
ttl=87600h > root_ca.crt
(AD CS equivalent: stand up the root CA on a machine that will be powered off and locked away the moment it’s done — Install-AdcsCertificationAuthority -CAType StandaloneRootCA, configure a long validity period, generate the CA cert, then disconnect it from the network. The “offline” part isn’t a suggestion; it’s the entire point of having a root.)
The root signs issuing CAs, and nothing else. It does not issue end-entity certificates, it is not network-reachable on a normal day, and its private key never touches a system that handles enrollment traffic. Every property of this hierarchy that makes a future compromise survivable flows from that one design decision.
Phase 1, Step 2 — the online issuing CA
The issuing CA is the workhorse — the thing that’s actually online, actually signs your FortiGates’ certificates, and therefore actually needs the operational care (patching, monitoring, key protection) that the root gets to skip by being switched off.
$ vault secrets enable -path=pki_int pki
$ vault secrets tune -max-lease-ttl=43800h pki_int # ~5 years
$ vault write -format=json pki_int/intermediate/generate/internal \
common_name="Garner Networks Issuing CA 01" \
key_type="ec" key_bits="256" \
| jq -r '.data.csr' > pki_int.csr
# -- sign the CSR with the (offline, briefly reconnected) root --
$ vault write -format=json pki_root/root/sign-intermediate \
csr=@pki_int.csr format=pem_bundle ttl=43800h \
| jq -r '.data.certificate' > pki_int.crt
$ vault write pki_int/intermediate/set-signed certificate=@pki_int.crt
(AD CS equivalent: the issuing CA is Install-AdcsCertificationAuthority -CAType EnterpriseSubordinateCA, joined to the domain so it can use certificate templates and Active Directory-integrated auto-enrollment policy, with its CSR signed by the offline root in the same “wake it up, sign, put it back to sleep” pattern.)
Two issuing CAs, not one, if your estate is large enough to make CA downtime during patching a real operational event — Vault and AD CS both support multiple intermediates chaining to the same root, and a second issuing CA is cheap insurance against “the CA server needs a reboot” turning into “nobody can enroll for twenty minutes.”
Phase 1, Step 3 — a role/template scoped to IPsec end entities
This is where the lifetime philosophy from Part 1 stops being philosophy and becomes parameters. Don’t reuse a general-purpose “issue me a cert” role for FortiGate IPsec — define one specifically for it, with the key usage, extended key usage, and validity window that an IKEv2 end-entity certificate actually needs and nothing more.
$ vault write pki_int/roles/fortigate-ipsec \
allowed_domains="edge.garnernetworks.internal,hub.garnernetworks.internal" \
allow_subdomains=true \
key_type="ec" key_bits="256" \
key_usage="DigitalSignature,KeyEncipherment" \
ext_key_usage="IPSECEndSystem,IPSECTunnel,IPSECUser" \
max_ttl="2160h" \ # 90 days — short, auto-renewing
generate_lease=false
(AD CS equivalent: a duplicated IPsec (Offline request) or Computer template, customized so Application Policies / Extended Key Usage carries IP security IKE intermediate and IP security end system, Key Usage is Digital Signature + Key Encipherment, and the validity period matches your short-leaf philosophy — then restricted via template permissions to the device accounts or enrollment service that should be allowed to request it.)
Notice the max_ttl="2160h" — ninety days. That number is doing real philosophical work: it’s short enough that the EST auto-renewal loop (which we wire up in Part 3) gets exercised roughly monthly per device — early enough in its lifetime that “renewal quietly broke” surfaces as a monitoring alert with weeks of runway, not as a 2 a.m. page when forty sites’ tunnels drop in the same hour because they all enrolled in the same provisioning batch six months ago. Long-lived leaf certificates don’t just increase blast radius on compromise — they hide the exact failure mode you most need to catch early.
Subject naming, decided here, not later. Set the common name to the device’s FQDN or a stable per-device identifier (site1-1.edge.garnernetworks.internal), and put the same value — or a tenant/region discriminator if you’re running multi-tenant — in a SAN. This is the naming convention that makes peertype peer filtering on the FortiGate side (Part 1’s closing point) a genuine access-control boundary rather than decoration. Decide it now, while it’s a config parameter; deciding it after you’ve issued five hundred certificates is a re-issuance project.
Phase 1, Step 4 — the EST front-end
Here’s the one place the Vault-as-worked-example breaks down a little: Vault’s PKI engine issues certificates over its own HTTP API, not natively over EST. To give FortiGates the EST endpoint they expect, you put an EST-speaking front-end in front of the issuing CA — a small dedicated EST gateway, or an EST/ACME-native CA such as Smallstep’s step-ca configured to issue from (or chain to) your Vault intermediate. AD CS shops have the more direct version of this same requirement: NDES handles SCEP natively, and you’d sit a SCEP-to-EST translating proxy in front of it, or move to a tooling stack with native EST support if your Windows Server version offers it.
Whichever component you choose, it needs to expose:
- An EST server URL the FortiGates will be pointed at — by convention, something like
https://pki.garnernetworks.internal:8443/.well-known/est/<ca-id>(the.well-known/estpath and the<ca-id>segment are part of the RFC 7030 URL convention, andest-ca-idis a literal field in the FortiGate’s local-certificate config, which we’ll wire up in Phase 2). - A bootstrap enrollment path — an HTTP-authenticated, one-time-credential flow for devices enrolling for the first time, before they hold a certificate to re-enroll with. This is the seam in EST’s elegance that Part 1 flagged, and it deserves its own design pass; we’ll build it properly in Part 3 rather than bolt it on as an afterthought here.
- TLS certificates of its own — issued from the same hierarchy, naturally, with a long enough lifetime that the EST endpoint isn’t itself stuck in a renewal bootstrap loop.
Place this front-end on a host that’s reachable from the underlay — every spoke’s WAN-side network — not buried behind the overlay it’s helping to build. We’re about to need that same property for revocation infrastructure, and it’s worth solving both placement problems with the same network design decision.
Phase 1, Step 5 — CRL and OCSP, placed where the chicken can reach the egg
Part 1 named the trap: a device needs the overlay up to reach revocation infrastructure that lives on the far side of the overlay, but the overlay needs valid, checked certificates to come up. The fix is placement, not protocol cleverness.
$ vault write pki_int/config/urls \
issuing_certificates="https://pki.garnernetworks.internal:8200/v1/pki_int/ca" \
crl_distribution_points="https://pki.garnernetworks.internal:8200/v1/pki_int/crl" \
ocsp_servers="https://pki.garnernetworks.internal:8443/ocsp"
$ vault write pki_int/crl/rotate # initial publish; automate this on a schedule
(AD CS equivalent: configure the CDP and AIA extensions on the issuing CA — certutil -setreg CA\CRLPublicationURLs / \CACertPublicationURLs — pointing at a host reachable the same way, and stand up the Online Responder role service if you want OCSP rather than CRL-only.)
The placement decision is the entire game here: pki.garnernetworks.internal resolves and routes over the underlay — the same WAN paths the FortiGates already use to reach the EST endpoint, to FortiManager, to NTP, to everything else that has to work before the overlay exists. It is not a host that’s only reachable once the IPsec tunnel to it is up. Get this one placement decision right and the chicken-and-egg problem simply doesn’t occur; get it wrong and you’ve built a system that passes every test in the lab (where everything can reach everything) and fails on day one in production (where a cold spoke can reach exactly the underlay and nothing else).
If you went the OCSP route, this is also where you decide CRL-as-fallback — most TLS/IKE stacks, FortiOS included, can be configured to fall back to CRL checking if an OCSP responder is unreachable. Configure both. A revocation check that silently soft-fails open because the primary method timed out is a worse outcome than a slightly slower check that succeeds.
Phase 2 — FortiManager’s (much smaller) job
Here’s the part that might be a little deflating if you came into this series expecting FortiManager to be the star: in this architecture, it isn’t, and that’s by design. FortiManager’s job is to push the configuration that tells every FortiGate where the PKI lives, what to trust, and how to enroll — the same “shape-invariant trust framework” role that Jinja Orchestrator Part 3 gave its Cert-Trust-Anchor classic CLI template, just pointed at an external hierarchy instead of FMG’s own.
That template carries four blocks that don’t vary between devices in the same project — write once, assign to the device group, leave alone:
config vpn certificate ca
edit "GarnerNet_Root"
set ca "-----BEGIN CERTIFICATE-----
... root_ca.crt contents ...
-----END CERTIFICATE-----"
next
edit "GarnerNet_Issuing01"
set ca "-----BEGIN CERTIFICATE-----
... pki_int.crt contents ...
-----END CERTIFICATE-----"
next
end
config vpn certificate crl
edit "GarnerNet_CRL"
set http-url "https://pki.garnernetworks.internal:8200/v1/pki_int/crl"
set update-interval 3600
next
end
config user peer
edit "GarnerNet_IPsec_Peers"
set ca "GarnerNet_Issuing01"
set subject "edge.garnernetworks.internal" # CN/SAN substring filter
next
end
config user peergrp
edit "GarnerNet_IPsec_Peers"
set member "GarnerNet_IPsec_Peers"
next
end
Note the full chain — root and issuing CA — both installed as trust anchors. That’s not redundancy; it’s how the FortiGate validates a presented certificate by walking it back through the issuing CA to the root, exactly the chain-of-trust mechanic Part 1 described as the substance of authmethod signature.
The fifth block is the one that’s genuinely new versus the FMG-as-CA version of this template: the EST client configuration that tells each FortiGate how to enroll and renew on its own.
config vpn certificate local
edit "EST-Edge-Cert"
set enroll-protocol est
set est-server "https://pki.garnernetworks.internal:8443/.well-known/est/fortigate-ipsec"
set est-ca-id "pki_int"
set est-server-cert "GarnerNet_Issuing01"
set est-http-username "<bootstrap-account>"
set est-http-password ENC <encrypted-bootstrap-password>
next
end
A few things worth being deliberate about in that block:
est-http-username/est-http-passwordare the bootstrap credential — the seam where a human (or a provisioning system) is still in the loop, exactly as Part 1 flagged. They authenticate the device’s first enrollment, before it has a certificate to re-enroll with via mutual TLS. We’ll design the lifecycle of this credential properly in Part 3 — short-lived, scoped, ideally per-batch rather than estate-wide — because a long-lived, widely-shared bootstrap credential is just a SCEP challenge password wearing an EST costume, and it would quietly undo most of what this architecture is for.est-server-certreferences the trust anchor, not a separate PEM blob — keeping the template’s invariant property intact: change the CA, change one object, every device that references it picks up the change on next install.- This block lives in the same classic CLI template as the trust-anchor objects, assigned to the same device group. One template, one install, one diff to review. That’s the Jinja Orchestrator philosophy applied without modification: anything shape-invariant belongs in a classic CLI template, sitting alongside whatever Jinja-rendered, shape-varying config builds the actual SD-WAN overlay — not forked into it.
If you push the CA PEM bundles through the template (rather than relying on some other distribution path), keep them in meta variables rather than literal in the template body — it keeps the template legible and lets you rotate an issuing CA by changing one variable and re-installing, not by hand-editing a wall of base64.
Gotchas
A second issuing CA is not optional at scale. One issuing CA means CA maintenance — patching, certificate renewal on the CA’s own cert, an unplanned reboot — is a window where no device can enroll or renew. At a hundred sites that’s an inconvenience you schedule around. At a thousand, with leaf certificates on a 90-day cycle, it’s a window where a meaningful fraction of your estate could be approaching expiry with nowhere to go. Stand up two from day one; it’s far cheaper than retrofitting it later.
The CRL/OCSP placement decision is a network-design decision, not a PKI-config decision. It’s tempting to treat crl_distribution_points as a line in a Vault config file. It’s actually a statement about your underlay routing and DNS — does every spoke, including the one in the branch office with the flaky DSL backup link, have a path to that hostname that doesn’t transit the overlay? If you can’t answer that with certainty for your worst-connected site, you haven’t finished this step.
Trust the whole chain, not just the leaf-signing CA. A FortiGate validating a peer’s certificate needs to walk it back to something it trusts. If you only push the issuing CA as a trust anchor and the root isn’t there too, validation either fails outright or — worse, depending on build and config — succeeds in the lab against a flat two-tier test setup and then fails in production against the real three-tier chain. Push the full chain, every time, as a matter of template hygiene.
The bootstrap credential is the one shared secret you haven’t eliminated yet. Everything in this design after first-enrollment is certificate-based, mutual-TLS, no-shared-secrets automation. The bootstrap step is the exception, and it’s exactly the kind of exception that quietly becomes permanent if nobody designs its lifecycle on purpose. Flag it now; we fix it properly in Part 3.
Where we’re going
The PKI exists. The trust anchors, revocation infrastructure, and EST endpoint are reachable from the underlay. FortiManager knows how to push the configuration that points devices at all of it. What’s still missing is the part that actually moves the needle operationally: getting that configuration onto a FortiGate that’s never seen this PKI before, automating the renewal that follows, and finally — the moment this whole series has been building toward — flipping the IPsec phase1 from psk to signature across the estate.
That’s Part 3: the bootstrap problem solved properly, enrollment and renewal monitored as a first-class operational concern, and the PSK-to-certificate cutover itself, walked the same operational way Jinja Orchestrator Part 3 walked its FMG-as-CA version — with an explicit diff against it, so if you’ve already read that post, you’ll see exactly where the two paths converge and where they don’t.