Skip to content

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 sub is the spiffe:// 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:

spiffe://<trust-domain>/<workload-path>
    e.g. spiffe://prod.example.org/ns/prod/sa/web

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:

  1. RBAC issue capability — the caller must be able to issue on the CA (the issuer/admin role, or a per-CA SIGN_CERTIFICATE grant), exactly like POST /api/ca/{id}/issue.
  2. 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_domains list, or granted to the caller's OIDC subject / verified email via subject_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:

  1. structural parse restricted to the allowed signature algorithms — none is refused;
  2. key lookup — the header kid must resolve to a jwt-svid key in the bundle, or the token is rejected as signed by an unknown key;
  3. signature verification against that key;
  4. 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;
  5. claim validation — the required aud must be present and, if the relying party named one, contain it; exp/nbf are 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):

curl -sk https://pki.example.com/api/ca/$CA_ID/svid/bundle
{
  "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_days window. 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, default 0.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 UpstreamAuthority at 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-spiffe expects them, and refresh before not_after (or on the bundle's spiffe_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 aud so 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 own aud.
  • 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.