SPIFFE SVID workload identity¶
Secsy PKI can mint SPIFFE SVIDs — short-lived
workload-identity credentials named by a spiffe:// URI — on top of the same
HSM-backed CA used for every other certificate. Two SVID forms are supported:
- X.509-SVID — an X.509 leaf certificate whose sole identity is the
spiffe://URI SAN. The workload generates its own key; the CA only signs. - JWT-SVID — a short-lived signed JWS bearer token whose
subis thespiffe://URI, for contexts where mutual-TLS is impractical (an HTTP API gateway, a message bus). The CA's HSM key signs the token; there is no workload key.
Every SVID is signed on the HSM; for X.509-SVIDs the workload private key is generated by the workload and never leaves it, and for JWT-SVIDs no key material leaves the token at all.
This document covers both SVID structures, the issuance API/CLI, the trust-domain
allowlist, aggressive short-TTL auto-renewal, the JWKS trust-bundle endpoint (now
carrying both X.509 and JWT verification keys), JWT-SVID validation, and how to
consume all of it from SPIRE-style clients (go-spiffe).
What is an X.509-SVID?¶
Per the SPIFFE X.509-SVID specification, an SVID is an ordinary X.509 leaf certificate with a specific shape:
| Field | Requirement | Secsy PKI |
|---|---|---|
subjectAltName |
exactly one URI SAN — the SPIFFE ID | ✅ the spiffe:// URI, sole identity |
| Subject CN | present but MUST NOT be relied upon | ✅ empty (no CN) |
| DNS / IP SANs | none required; discouraged | ✅ none by default |
basicConstraints |
CA: false |
✅ enforced (leaf) |
keyUsage |
MUST include digitalSignature; MUST NOT include keyCertSign/cRLSign |
✅ digitalSignature only |
extKeyUsage |
recommended id-kp-serverAuth + id-kp-clientAuth (mutual TLS) |
✅ both |
| validity | short-lived | ✅ 1 hour default (24 h max) |
These are enforced by the built-in spiffe-svid issuance profile, plus the
CA-layer IssueSVID path which fixes the URI SAN and clears the subject. The
pre-issuance lint gate independently rejects any SVID that asserts CA:true or
carries a CA key usage, so a mis-edited custom profile fails closed.
A SPIFFE ID looks like:
The trust domain is the root of trust (a SPIFFE deployment); the path names a
workload within it. Secsy PKI validates both strictly (RFC-style syntax: no
port, no userinfo, no query/fragment, no empty or ./.. path segments).
Enabling SVID issuance¶
Add a spiffe block to the server config (see config.yaml):
spiffe:
enabled: true
trust_domains: # global allowlist (fail-closed: empty = none)
- prod.example.org
- staging.example.org
# subject_trust_domains: # optional per-subject grants (RBAC-keyed)
# "team-a@example.com": [team-a.example.net]
profile: spiffe-svid # default; a custom profile may override validity
default_ca_id: "" # CA used when a request omits one
refresh_hint_seconds: 300 # advertised in the trust bundle
renew_fraction: 0.5 # monitor renews at 50% of an SVID's lifetime
# --- JWT-SVID ---
jwt_default_audience: [] # applied when a request omits aud (empty = aud required)
jwt_default_ttl_seconds: 3600 # token lifetime when a request omits one (default 1h)
jwt_max_ttl_seconds: 86400 # hard ceiling on a token lifetime (default 24h)
When enabled the server registers three endpoints per CA:
POST /api/ca/{id}/svid— mint an X.509-SVID (authenticated + authorized).POST /api/ca/{id}/svid/jwt— mint a JWT-SVID (authenticated + authorized).GET /api/ca/{id}/svid/bundle— fetch the trust bundle (public), carrying both the X.509 (x509-svid) and JWT (jwt-svid) verification keys.
Trust-domain allowlist (authorization)¶
Minting an SVID is authorized in two layers:
- RBAC issue capability — the caller must be able to issue on the CA
(the
issuer/adminrole, or a per-CASIGN_CERTIFICATEgrant), exactly likePOST /api/ca/{id}/issue. - Trust-domain allowlist — the requested trust domain must be permitted for
the caller. A trust domain is permitted if it is in the global
trust_domainslist, or granted to the caller's OIDC subject / verified email viasubject_trust_domains.
Both layers are fail-closed: with no allowlist entry, no SVID is issued. A denied
request is recorded in the tamper-evident audit log (svid.issue for an
X.509-SVID, svid.issue_jwt for a JWT-SVID / ResultDenied) with the offending
trust domain. The same two-layer authorization gates both SVID forms and is
tenant-scoped: the caller must be authorized on the CA's tenant.
This lets you delegate SVID issuance for a specific trust domain to a specific team's credential without granting them the whole CA.
Issuing an X.509-SVID¶
API¶
The workload generates a key and a CSR; only the public key is taken from the CSR (its subject and SANs are ignored). The identity is supplied out-of-band:
curl -sk -u root:secret \
-X POST https://pki.example.com/api/ca/$CA_ID/svid \
-H 'Content-Type: application/json' \
-d '{
"csr": "'"$(awk 'BEGIN{ORS="\\n"}1' workload.csr)"'",
"trust_domain": "prod.example.org",
"path": "/ns/prod/sa/web",
"ttl_seconds": 3600
}'
You may instead pass "spiffe_id": "spiffe://prod.example.org/ns/prod/sa/web".
The response carries the X.509-SVID, its chain, and the trust bundle so a workload has everything it needs from one call:
{
"spiffe_id": "spiffe://prod.example.org/ns/prod/sa/web",
"trust_domain": "prod.example.org",
"certificate": "-----BEGIN CERTIFICATE----- ...",
"chain": "-----BEGIN CERTIFICATE----- ...(leaf + issuer)...",
"bundle": "{ \"keys\": [ ... ], \"spiffe_refresh_hint\": 300 }",
"serial": "…",
"profile": "spiffe-svid",
"not_before": "2026-07-02T12:00:00Z",
"not_after": "2026-07-02T13:00:00Z"
}
CLI¶
secsy-ca svid mints an SVID from a CSR, or (for convenience) generates an
ECDSA P-256 key locally:
# Generate a workload key + SVID in one step:
secsy-ca svid -ca "Prod Intermediate" \
-trust-domain prod.example.org -path /ns/prod/sa/web \
-ttl 30m -key-out workload.key -out workload.svid.pem -chain
# Or from an existing CSR:
secsy-ca svid -ca "Prod Intermediate" \
-id spiffe://prod.example.org/ns/prod/sa/web \
-csr workload.csr -out workload.svid.pem
Issuing a JWT-SVID¶
A JWT-SVID is a short-lived signed JWS bearer token. Unlike an X.509-SVID there is no CSR and no workload key — the CA's HSM-backed signing key signs a set of claims:
| Claim / header | Requirement | Secsy PKI |
|---|---|---|
sub |
the SPIFFE ID | ✅ the spiffe:// URI |
aud |
required, ≥1 value; the relying party rejects a token whose aud does not include it |
✅ required (from the request or a server default) |
exp |
required, short-lived | ✅ 1 h default (configurable), 24 h ceiling (tightenable, never exceeded) |
iat / nbf |
issuance / not-before | ✅ set (nbf backdated for clock skew) |
alg |
a signing algorithm, never none |
✅ derived from the CA key (ES256/384/512, RS256, EdDSA) |
kid |
identifies the verification key | ✅ RFC 7638 thumbprint of the CA key — matches the JWKS bundle |
The token is signed on the HSM through the key provider; no private key material
is handled in process. The kid is a deterministic thumbprint of the signing
key, so it always resolves to that key's jwt-svid entry in the trust bundle.
API¶
curl -sk -u root:secret \
-X POST https://pki.example.com/api/ca/$CA_ID/svid/jwt \
-H 'Content-Type: application/json' \
-d '{
"spiffe_id": "spiffe://prod.example.org/ns/prod/sa/web",
"audience": ["spiffe://prod.example.org/ns/prod/sa/db"],
"ttl_seconds": 300
}'
The response carries the compact token and the JWKS trust bundle (with the JWT verification keys), so a relying party has everything to validate it:
{
"token": "eyJhbGciOiJFUzI1NiIsImtpZCI6Ii4uLiIsInR5cCI6IkpXVCJ9…",
"spiffe_id": "spiffe://prod.example.org/ns/prod/sa/web",
"trust_domain": "prod.example.org",
"audience": ["spiffe://prod.example.org/ns/prod/sa/db"],
"kid": "GnQ…RFC7638-thumbprint…",
"alg": "ES256",
"issued_at": "2026-07-04T12:00:00Z",
"expires_at": "2026-07-04T12:05:00Z",
"bundle": "{ \"keys\": [ … ] }"
}
CLI¶
# Mint a JWT-SVID (audience is required; repeatable or comma-separated):
secsy-ca svid jwt -ca "Prod Intermediate" \
-id spiffe://prod.example.org/ns/prod/sa/web \
-audience spiffe://prod.example.org/ns/prod/sa/db \
-ttl 5m -out workload.jwt
# Validate one against the CA's trust bundle (offline, HSM not required):
secsy-ca svid jwt-verify -ca "Prod Intermediate" \
-audience spiffe://prod.example.org/ns/prod/sa/db \
-token workload.jwt
Validating a JWT-SVID¶
The relying party verifies a token against the trust bundle: it looks up the
header kid among the bundle's jwt-svid keys, checks the signature, then
enforces the claims. Secsy PKI ships a server-side helper (spiffe.ValidateJWTSVID)
that does exactly this, and it is what the svid jwt-verify CLI calls:
- structural parse restricted to the allowed signature algorithms —
noneis refused; - key lookup — the header
kidmust resolve to ajwt-svidkey in the bundle, or the token is rejected as signed by an unknown key; - signature verification against that key;
- trust-domain allowlist — the
sub's trust domain must be permitted (the same allowlist that gates issuance), rejecting a foreign trust domain even when the signature is valid; - claim validation — the required
audmust be present and, if the relying party named one, contain it;exp/nbfare checked within a small clock-skew leeway.
Any failure is a hard rejection. A successful issuance or validation is recorded
in the audit log (svid.issue_jwt) and counted in the
secsy_certificates_total{operation="svid_jwt_issue"} metric.
The trust bundle (JWKS)¶
A SPIFFE trust bundle is the set of authorities a consumer uses to validate
SVIDs from a trust domain. Secsy PKI serves it in the JWKS-style format defined
by the SPIFFE
Trust Domain and Bundle spec
and emitted by SPIRE. Each authority appears twice: once as an x509-svid
key (the DER certificate in x5c, anchoring X.509-SVIDs) and once as a
jwt-svid key (the bare public key with a kid, verifying JWT-SVIDs signed by
that authority's HSM key):
{
"keys": [
{ "kty": "EC", "crv": "P-256", "x": "…", "y": "…",
"use": "x509-svid", "x5c": ["<base64 DER>"] },
{ "kty": "EC", "crv": "P-256", "x": "…", "y": "…",
"use": "jwt-svid", "kid": "GnQ…RFC7638-thumbprint…" }
],
"spiffe_refresh_hint": 300
}
The bundle contains the CA's combined overlap chain — the active issuer, any
overlapping keys still inside a rollover window (see
ca-rotation.md), and the ancestors up to the root — so it keeps
validating SVIDs across an intermediate-key rotation without a bundle swap. The
jwt-svid keys cover the same overlap set, so a JWT-SVID minted just before a
key rollover still verifies afterward (only the active issuer ever signs, but
any overlap-window key may verify).
secsy-ca svid-bundle -ca "Prod Intermediate" emits the same document.
Aggressive short-TTL auto-renewal¶
SVIDs are deliberately short-lived, so the certificate-expiry monitor
(see expiry-monitoring.md) treats them specially when
monitor.auto_renew is enabled:
- Fraction-based renewal. An ordinary certificate renews inside the absolute
renew_before_dayswindow. That is meaningless for a 1-hour SVID, so SVIDs instead renew once a fixed fraction of their own lifetime has elapsed (spiffe.renew_fraction, default0.5→ renew a 1 h SVID with ~30 min left). - Per-workload identity. SVIDs share an empty subject, so the monitor keys
supersession on the
spiffe://URI SAN instead — every workload rotates independently, and a freshly renewed SVID supersedes the prior one (no renewal storm across scans).
Renewal reuses the workload's existing key and URI SAN and reissues on the HSM,
minting a new short-lived SVID. Workloads that rotate their own keys can simply
call POST …/svid again on their own cadence instead of relying on the monitor.
Integrating with SPIRE-style consumers (go-spiffe)¶
The bundle and SVID formats are wire-compatible with
go-spiffe/v2. A relying
service can validate peers against the trust bundle without SPIRE:
import (
"github.com/spiffe/go-spiffe/v2/bundle/spiffebundle"
"github.com/spiffe/go-spiffe/v2/spiffeid"
"github.com/spiffe/go-spiffe/v2/svid/x509svid"
)
td := spiffeid.RequireTrustDomainFromString("prod.example.org")
// Fetch and parse the JWKS trust bundle served by Secsy PKI.
raw, _ := http.Get("https://pki.example.com/api/ca/" + caID + "/svid/bundle")
body, _ := io.ReadAll(raw.Body)
bundle, _ := spiffebundle.Parse(td, body)
// Load the SVID (leaf + chain PEM) and its key on the workload side.
svid, _ := x509svid.Load("workload.svid.pem", "workload.key")
// Use bundle + svid with go-spiffe's tlsconfig to mint an mTLS config that
// authenticates peers by SPIFFE ID.
Because the SVID carries serverAuth + clientAuth EKUs and a single URI SAN,
go-spiffe's tlsconfig.MTLSServerConfig / MTLSClientConfig accept it as-is.
Bridging to a real SPIRE deployment¶
Secsy PKI is not a SPIRE server, but it composes with one as an upstream CA:
- Point a SPIRE server's
UpstreamAuthorityat Secsy PKI (via the ACME or CMP enrollment endpoints) so SPIRE mints SVIDs down-chain from your HSM-backed root. The trust bundle Secsy PKI serves is then the upstream anchor SPIRE advertises to agents. - Or use Secsy PKI directly as a lightweight SVID source for workloads that
cannot run a SPIRE agent (CI jobs, serverless, edge devices): fetch an SVID +
bundle over the API, drop the PEMs where
go-spiffeexpects them, and refresh beforenot_after(or on the bundle'sspiffe_refresh_hint).
Security notes¶
- Key custody. X.509-SVID private keys are generated by the workload; the CA only ever sees the CSR public key. CA signing happens on the HSM. A JWT-SVID carries no key at all — it is signed on the HSM and handed out as a token.
- Bearer-token caution. A JWT-SVID is a bearer credential: anyone holding it
can present it until it expires. Keep its TTL short (minutes), always set a
specific
audso a token minted for one service cannot be replayed against another, and prefer the X.509-SVID + mutual-TLS path where proof-of-possession matters. Validation always requires the relying party's ownaud. - No name confusion. The CSR's own subject/SANs are discarded — an SVID's
identity is exactly the trust-domain-authorized
spiffe://URI, so a workload cannot smuggle a DNS name or a different SPIFFE ID into its certificate. - Fail-closed authorization. An unlisted trust domain, a missing issue capability, or a malformed SPIFFE ID all deny issuance and are audited.
- Short lifetimes. The default 1-hour validity bounds the blast radius of a leaked SVID key; pair it with monitor auto-renewal (or workload-driven re-issuance) to keep identities fresh.