SPIFFE
iya-sts is a SPIFFE issuing authority in all three of its server-side
shapes: the bundle endpoint over HTTPS, the SPIFFE Workload API over
gRPC, and the SPIRE Server API over gRPC. It issues X509-SVIDs and JWT-SVIDs
per the SPIFFE specifications
(SPIFFE ID, Trust Domain and Bundle, X509-SVID, JWT-SVID, Workload API), and its
server API is SPIRE’s own
spire-api-sdk protocol. Every
trust realm can be a trust domain of its own, with its own
authority and its own sockets on an address of its own.
| Surface | Transport | Who talks to it |
|---|---|---|
| The bundle endpoint | HTTPS, GET /spiffe/bundle |
a federation partner, or anybody verifying an SVID |
| The Workload API | gRPC on a Unix socket, and on TCP | a workload, to be given an identity |
| The SPIRE Server API | gRPC on TCP (mutual TLS), and optionally a Unix socket | an operator and an agent: entries, attestation, bundles, minting |
The three have almost nothing in common with each other. The bundle endpoint is one GET returning a JWK Set, and it is the whole of the SPIFFE federation protocol’s server half. The two gRPC surfaces are authenticated in opposite ways, because their specifications demand opposite things (see Authentication on the two gRPC surfaces).
Features
Trust domains and the authority
A realm’s trust domain is the authority part of every SPIFFE ID it mints. The
default realm’s is spiffe.trustDomain (example.org). A realm created at
runtime gets its own DNS domain as its trust domain, for example iyasec.io,
and may name another one outright. Two realms never share a trust domain by
default, because two authorities claiming one name make every SVID either one
mints ambiguous.
The trust domain is fixed when the realm’s authorities are built. A later change
is reported as drift on GET /spiffe and /admin/spiffe, not acted on. To
change it, turn the realm’s SPIFFE off and on again, which discards the old
authorities.
A realm gets sockets of its own, and it is told apart by ADDRESS. Every other
family here learns its realm from a segment at the front of the path; gRPC’s
path is the METHOD name, fixed by the Workload API specification, so there is
nowhere in the protocol to put one. A realm is created with spiffe.enabled
off. Turning it on builds that realm’s authorities and binds a Workload API
and a SPIRE Server API of its own, on the address spiffe.grpcHost names for
that realm and on the same ports. That is also what a real deployment looks
like — one SPIRE server is one trust domain — and it is why
docker-compose.yml gives the container four addresses rather than one. Join
tokens are per realm for the same reason: a join token is a credential for
joining a trust domain.
The X.509 authority is the realm’s SPIFFE Issuing CA in the service’s certificate hierarchy:
Root CA (the service's) <- what the bundle publishes
└── Intermediate CA (this realm)
└── SPIFFE Issuing CA <- signs X509-SVIDs
├── X509-SVID
└── downstream CA (NewDownstreamX509CA)
This is the arrangement SPIRE has with an UpstreamAuthority configured. A
rotation of the Issuing CA leaves the bundle unchanged, because the bundle is
the Root. Where no hierarchy exists (pki.autoBuild off, or a Root that could
not be built), the realm falls back to a self-signed X.509 authority. That
authority’s rotations keep spiffe.retainedAuthorities authorities in the
bundle. The JWT authority is always the realm’s own key.
The SPIFFE authority is not the TLS certificate. The certificate LDAPS 636,
the embedded debugger’s listener and (under global.https) the main port share
is a leaf with CA:FALSE and extKeyUsage serverAuth; it cannot sign anything,
and a trust domain’s CA and a host’s TLS identity are two unrelated trust
decisions. They share an anchor — the service Root — and not a tree: the
SPIFFE Issuing CA is a sibling of the TLS one, with its own key under its own
Intermediate, so trust narrowed to SPIFFE alone is still sayable by pinning the
SPIFFE Issuing CA instead of the Root. It is the one Issuing CA in the hierarchy
with pathLen: 1 rather than 0, because NewDownstreamX509CA asks it for a
CA and not a leaf, and the realm Intermediate above it is widened to 2 to
match.
The bundle publishes the Root, which is what SPIRE publishes with an
UpstreamAuthority plugin, and an X509-SVID carries its Issuing CA and the
realm’s Intermediate in its own chain. The authority is per realm and the anchor
is not, so every realm’s X.509 bundle is the same document.
Rotation is two different acts. Under the Root, rotating re-issues the
SPIFFE Issuing CA and leaves the anchor alone: the bundle does not change,
nothing has to be re-fetched, and SVIDs minted under the old authority go on
verifying, because their chain still reaches the same Root. On the
self-signed fallback (which /admin/spiffe and GET /spiffe report, and
which pki.autoBuild: false reaches deliberately) a rotation prepends and
never replaces: the new authority signs everything from then on, and the old
one stays published, because an SVID minted a minute ago has to go on
verifying — dropping it is the difference between a rotation and an outage.
Past spiffe.retainedAuthorities the oldest is dropped, which does invalidate
whatever it signed, and the page says so.
Both authorities rotate automatically, in both modes. A scheduler job runs every
hour. It rotates the X.509 authority once it is past half its lifetime, and the
JWT authority once it is older than half spiffe.caTtl. Rotate now on
/admin/spiffe rotates either or both by hand. On a cluster, a realm’s
authorities are made once and adopted by every node.
X509-SVIDs use EC P-256 by default (spiffe.x509KeyType), which is what SPIRE
issues. JWT-SVIDs are signed ES256 by default (spiffe.jwtKeyType). Ed25519 is
offered for X.509 only.
The bundle endpoint
GET /spiffe/bundle (spiffe.bundlePath) returns the realm’s trust bundle as a
JWK Set, with spiffe_sequence (which changes only when the bundle changes) and
spiffe_refresh_hint (spiffe.refreshHint). Every key carries a use of
x509-svid (with the certificate in x5c) or jwt-svid. It is served
no-store. A real federation partner refuses a plain-HTTP bundle endpoint, so
set global.https.
GET /spiffe/federated/{trustDomain} shows a foreign trust domain’s bundle
exactly as this realm holds it, so that “the bundle I pushed” and “the bundle
workloads are given” can be compared.
The Workload API
The gRPC service SpiffeWorkloadAPI, on:
- a Unix socket at
spiffe.workloadSocket, SPIRE’s own default path/tmp/spire-agent/public/api.sock, soSPIFFE_ENDPOINT_SOCKETneeds no change for a client that was pointed at a SPIRE agent; - TCP at
spiffe.workloadPort(8092), for a caller in another container. In product mode only where the network authenticates source addresses: see A caller over TCP below.
Five of the specification’s seven methods are implemented:
FetchX509SVID: X509-SVIDs with their private keys and the bundle;FetchX509BundlesandFetchJWTBundles;FetchJWTSVID: a JWT-SVID for an audience, which is required;ValidateJWTSVID: a real verification, returning the claims.
The two WIT methods are not implemented. No specification defines the token format yet, and inventing one would work here and interoperate with nothing.
The streams stay open. FetchX509SVID and the bundle streams are held for
the life of the connection and re-sent at half the shortest SVID lifetime,
so a client’s rotation path runs without an hour’s wait. A Workload API that
writes once and ends the stream looks completely correct on the first fetch and
puts go-spiffe into a reconnect loop, and a client’s rotation handling is the
part most worth exercising. A call without the
metadata header workload.spiffe.io: true is refused, because the specification
requires it (section 3, a hardening measure against server-side request
forgery). spiffe.requireSecurityHeader off serves such a call in development
mode only (#181); product ignores it and refuses turning it off.
Which entries answer a caller. A caller is given the registration entries
whose selectors are a subset of the caller’s selectors, as SPIRE matches
them (spiffe.attestWorkloads; turning it off, which hands every caller every
entry, is development only). The entry’s selectors must be a subset of the
caller’s — not equal to them, and not merely intersecting. A caller that matches
no entry gets an empty SVID list, which is what a real agent answers an
unregistered workload, and is the only way to exercise a client’s “I have no
identity” path — one most client libraries have and almost nobody runs. In
development, spiffe.autoCreateEntries instead creates an entry for the caller
and issues it an SVID. The invented entry carries the caller’s stable
selectors — transport and endpoint, never the peer, whose port is ephemeral — so
the next caller of the same shape matches it instead of inventing another.
Sample entries. A development start seeds three registration entries —
/workload, /ns/default/sa/web (with DNS names and a hint) and
/ns/default/sa/db (with a different hint) — for the same reason the directory
seeds three people: a Workload API that answers an empty list teaches a client
author nothing, and the first thing they will do is assume they have
misconfigured something. The two hints exist so that “more than one SVID came
back” is a path a client can actually be driven down. Delete them and they stay
deleted until a restart.
Authentication on the two gRPC surfaces
This looks like an inconsistency and is not: it is two documents making two different demands, and getting either the other way round breaks a real client.
The Workload API must not authenticate anybody. The SPIFFE Workload Endpoint specification says the endpoint “MUST NOT require any direct authentication of its clients”, and that “Transport Layer Security MUST NOT be required”. The reason is bootstrapping: a workload has no secret and no root of trust until this very call gives it one, so there is nothing it could present. A server that demanded a credential here would refuse every conforming client, which is why the mutual TLS of the other surface deliberately does not reach it. What the Workload API does instead is attest (below).
The SPIRE Server API is mutual TLS with an X509-SVID. A real spire-server
binds a TCP port whose callers present an SVID from the trust domain, takes the
caller’s SPIFFE ID off the certificate, and authorizes every method against
what that caller is; its private Unix socket is trusted outright, which is how
the spire-server CLI works. This service does the same: the SPIFFE ID comes
from the URI subjectAltName, never from the subject, and the method is
checked against SPIRE’s own table. It is not a setting and cannot be turned
off; the socket is bound as mutual TLS on every start.
The bootstrapping case is handled the way SPIRE handles it: the port asks for a
client certificate and does not require one, because AttestAgent is open to a
caller that has no SVID yet. Fetch the bundle from the bundle endpoint, verify
this server against it, attest, and come back with what you were issued.
Workload attestation on the Unix socket
The Workload API authenticates nobody, because its specification says it
MUST NOT: a workload has no credential until this call gives it one. What it
does is attest, as a SPIRE agent does. When a connection is accepted on the Unix
socket, the kernel names the connecting process (SO_PEERCRED and a pidfd, read
by a small native module built into the image). The workload attestors in
spiffe.workloadAttestors then turn that process into SPIRE’s selectors:
unix:uid:,user:,gid:,group:and the supplementary groups; withspiffe.unixDiscoverWorkloadPath, also the executable’spath:andsha256:.docker: the container’slabel:,env:,image_id:andimage_config_digest:, asked of the Docker Engine atspiffe.dockerSocketPath.k8s: the pod’ssa:,ns:,pod-name:,pod-label:,pod-owner:,container-name:,container-image:and the rest of SPIRE’s list, read from the kubelet.systemd: the unit’sid:andfragment_path:, asked of systemd over D-Bus. Install the optional packagedbus-nextin the image (STS_CLOUD_SDKS=dbus-nextat build time); without it, a realm namingsystemdrefuses every connection and says which package is missing.
Podman. The docker attestor asks Podman’s Docker-compatible API instead of
the Engine when the container’s cgroup says libpod: the rootful socket
(spiffe.dockerPodmanSocketPath), or a rootless user’s socket
(spiffe.dockerPodmanSocketPathTemplate) — the rootless one only with
spiffe.dockerUseRootlessPodman on, because that socket belongs to the caller.
The selectors are still docker:.
Signed images. With spiffe.dockerSigstoreEnabled, a docker workload’s
image must carry a cosign signature that verifies, and the attestation adds
SPIRE’s image-signature:verified, image-attestations:verified and
image-signature-subject:, -issuer:, -value:, -log-id:, -log-index:,
-integrated-time: and -signed-entry-timestamp: selectors. Configure:
- what a signature may verify under — cosign public key FILES
(
spiffe.dockerSigstorePublicKeyFiles), and for keyless signatures the sigstore trust root: through TUF (spiffe.dockerSigstoreTufRootFile, theroot.jsonyou trust, refreshed by the scheduler jobspiffe.sigstore-tuf-refresh), or pinned (spiffe.dockerSigstoreTrustedRootFile); - for keyless signatures, the signers you accept
(
spiffe.dockerSigstoreAllowedIdentities,issuer=subject); - the registries the signatures may be fetched from
(
spiffe.dockerSigstoreAllowedRegistries) — Docker Hub isindex.docker.io, and list its token hostauth.docker.ioand blob host too.
Every signature must carry its Rekor transparency-log bundle, and a keyless
certificate an embedded SCT. A signature that does not verify refuses the
connection (UNAVAILABLE); it never merely leaves out a selector.
A connection is attested once, when it is accepted. Every call on it checks
that the process is still the one attested. A process that has exited, a
reused pid or an exec of another program is refused PERMISSION_DENIED. An
attestor that fails refuses every call on the connection with UNAVAILABLE. A
peer in a pid namespace this service cannot see is attested on its kernel uid
and gid alone.
A caller over TCP
A caller over TCP is not attested, because there is no peer process to ask.
It is identified by the selectors transport:, endpoint: and peer: — the
transport it arrived on, the endpoint it reached and its peer address — and by
nothing else. These are deliberately spelt unlike any attestor’s, and never
unix: or k8s:: writing unix:uid:1000 for a uid nothing read would be
inventing an attested fact, the same offence as minting a credential format
nobody specified. In development, any caller that can reach the TCP port gets an
identity, and what comes out is a credential another service will believe.
Asserted selectors. In development, with spiffe.acceptAssertedSelectors
on, a caller may also assert its own selectors in an x-sts-workload-selector
metadata header. They are passed through verbatim, because they are the
caller’s claim rather than this service’s invention, and nothing verifies
them. The setting is off by default and refused in product. It exists because
selector matching is the interesting behaviour of a Workload API, and without
it a TCP client’s “these matched and those did not” path could not be run here
at all.
The Workload Endpoint specification allows TCP only where “the underlying network allows the Workload Endpoint server to strongly authenticate the workload based on source IP address” (section 3), and forbids the other fixes: TLS must not be required and a client must not be asked to authenticate. The source address is therefore the only identity a TCP caller carries, and whether the network guarantees it is something this service cannot see. So, in product mode (#166):
- The TCP port is not bound (
STS-SPIFFE-0120) unlessspiffe.workloadTcpSourceAuthenticatedis on. Turning it on declares the section 3 condition: a pod network with anti-spoofing, a host-only bridge. Warning: every host that reaches the port from an address an entry selects is issued that entry’s SVIDs, so an address that can be spoofed, shared behind a NAT or reassigned hands the identity to whoever holds it. - With it on, a wildcard
spiffe.grpcHost(0.0.0.0,::) is still refused (STS-SPIFFE-0121). Name the address on the network you vouch for. - A realm switched to product with the port already bound keeps the socket
and refuses every call on it (
UNAVAILABLE). - An entry must select something that identifies its workload: never only
transport:andendpoint:, which every caller of the port carries, and never nothing (STS-SPIFFE-0122). The console,/admin-apiand the SPIRE Server API (INVALID_ARGUMENTper item) all refuse it. For a TCP caller that ispeer:<address>, matched exactly:peer:10.0.0.0/24is a selector no caller carries, as in SPIRE. An entry written in development answers nobody once the realm is in product (STS-SPIFFE-0123).
GET /spiffe, /admin/spiffe and GET /admin-api/spiffe report the realm’s
state under workloadAttestation.tcp: served (development, not attested),
not served (product, source not declared authenticated), not served
(product, wildcard bind address) or served (product, source declared
authenticated), with whether the port is listening.
The SPIFFE Broker API
A broker — a node proxy, a service mesh’s per-node component — can ask for the SVIDs of a workload it acts for, by naming the workload rather than being it. This is the SPIFFE Broker API (Incubating), on a mutual-TLS listener of its own:
- Set
spiffe.brokerPorton the realm (andspiffe.grpcHostwhere the realm needs an address of its own), then turn the realm’s SPIFFE on. - Authorize the broker on SPIFFE → Brokers (
/admin/spiffe/brokers) orPOST /admin-api/spiffe/brokers/setwith its SPIFFE ID and the references it may use:pid,k8sor*. A broker from another trust domain needs that domain’s bundle federated first. - The broker connects with its X509-SVID, verifies this server as
spiffe://<trust domain>/spire/serveragainst the bundle, and sendsbroker.spiffe.io: trueon every call.
It calls SubscribeToX509SVID, SubscribeToX509Bundles, FetchJWTSVID and
SubscribeToJWTBundles, each with a workload reference:
- a
WorkloadPIDReference— a process on this host, attested by the workload attestors exactly as a Workload API caller is. The endpoint is TCP, so allowpidonly to a broker running on this host; - a
KubernetesObjectReferenceto a pod (pods, groupcore), by UID or by namespace and name — found in this node’s kubelet pod list and attested by thek8sattestor’s pod selectors. Other Kubernetes objects are refused.
The workload gets the entries its selectors match, never an admin or downstream
entry. A refusal follows the specification’s table — INVALID_ARGUMENT for a
missing header or a bad reference, UNAUTHENTICATED for no SVID,
PERMISSION_DENIED for a caller that is not a broker, a reference type it may
not use or a workload with no entry, NOT_FOUND for a workload that does not
exist — with a google.rpc.ErrorInfo in the spiffe.io domain. A stream ends
NOT_FOUND when its workload stops.
The SPIRE Server API
SPIRE’s six services, Entry, Agent, Bundle, SVID, TrustDomain and Debug, with
forty-two methods, on TCP spiffe.serverPort (8181, because SPIRE’s own 8081 is
this service’s HTTP port). They are also on a Unix socket at
spiffe.serverSocket when spiffe.serverSocketEnabled is on. Thirty-six
methods are implemented. GET /spiffe lists every method, with a reason for
each of the six that are not.
The TCP port is mutual TLS, in every mode. The server presents its own SVID
(spiffe://<trust domain>/spire/server). A caller presents an X509-SVID, which
is verified against the bundle, its validity window (with spiffe.clockSkew)
and the revocation register. Revoking the SPIFFE Issuing CA on /admin/pki
therefore refuses every SVID under it. The caller’s SPIFFE ID is taken from the
URI subjectAltName. The port asks for a certificate without requiring one,
so that AttestAgent and GetBundle stay open to an agent that has no SVID
yet.
Each method is authorized against SPIRE’s own per-method table, copied row
for row from policy_data.json. A caller is one or more of these entities, and
the check asks whether it is any of the ones a method allows — the
spire-server CLI on this host is local, and an agent that also holds an
entry marked admin is both:
| Entity | What it means |
|---|---|
local |
the call came in on the Unix socket while spiffe.trustLocalSocket is on — trusted outright in development; in product only when the socket is verified 0600 in a private directory and the caller’s kernel uid is the service’s own |
agent |
an attested, unbanned agent’s SVID |
admin |
a SPIFFE ID in spiffe.adminIds, or one with a registration entry marked admin |
downstream |
a SPIFFE ID with a registration entry marked downstream (a nested SPIRE server) |
| anonymous | nothing verified |
Where a row looks surprising, that is SPIRE’s own answer. For example,
Debug.GetInfo is local-only, so an admin SVID over TCP is refused it. After
SPIRE’s table has allowed a call, the XACML access gate is asked. It
permits by default. An authenticated caller gets a session keyed
on its SPIFFE ID.
A method the caller is not allowed is refused UNAUTHENTICATED when nothing was
presented and PERMISSION_DENIED when something was and it was not enough.
Those are different instructions to a client — “authenticate” and “you may
not” — and SPIRE distinguishes them; collapsing them sends a client that needs a
credential looking for a permission it will never get.
The admin and downstream flags on a registration entry are read on every
call, and nothing caches them, so an ldapmodify of spiffeAdmin under
ou=spiffe — or the form on /admin/spiffe/entries — changes what that
identity may do on the next call.
RenewAgent renews the agent on the connection, never an agent named in the
request: renewing whichever agent the caller named would be a way for anybody to
obtain any agent’s identity.
Node attestation
Agent.AttestAgent accepts only an attestation type that the realm lists in
spiffe.nodeAttestors and that this server can verify. Anything else is
refused FAILED_PRECONDITION, as SPIRE refuses an attestor it has no plugin
for. There is no fallback. All nine of SPIRE’s server node attestors are
implemented, step for step:
| Type | What is verified | Configured by |
|---|---|---|
join_token |
a single-use token from CreateJoinToken, per realm, spent once across every node |
spiffe.joinTokenTtl, spiffe.maxJoinTokens |
x509pop |
a certificate chaining to a configured bundle (or to the realm’s SPIFFE bundle), and a signature over a fresh challenge. RSA, ECDSA and, beyond SPIRE, post-quantum keys | spiffe.x509pop* |
sshpop |
an SSH host certificate from a configured authority, and a signature by its host key | spiffe.sshpop* |
tpm_devid |
a DevID key resident in a TPM whose endorsement key a configured manufacturer certified, proved by credential activation | spiffe.tpm* |
k8s_psat |
a projected service account token that the cluster’s own TokenReview authenticates | spiffe.k8sPsatClusters |
http_challenge |
a nonce served over HTTP from a host name the realm allows | spiffe.httpChallenge* |
aws_iid |
the EC2 instance identity document, with EC2 and IAM asked for selectors | spiffe.awsIid* |
gcp_iit |
the Compute Engine instance identity token | spiffe.gcpIit* |
azure_imds |
the attested document from Azure IMDS, minted for this server’s nonce | spiffe.azureImds* |
An attestor returns only selectors it verified. A trust anchor is PEM text
in a setting, not a file path, so the console and /admin-api can set it. No
credential is ever a setting. Where SPIRE takes an access key, an app secret
or a kubeconfig, this server takes a file path or uses the cloud SDK’s own
credential chain. The cloud SDKs are optional packages: a realm that enables
aws_iid, gcp_iit or azure_imds without the SDK installed is refused, and
the refusal names the missing package.
Evidence that cannot be re-attested, namely a join token and the
trust-on-first-use cloud documents (and http_challenge with spiffe.httpChallengeTofu), attests
once. Attesting the same agent again is refused until an operator deletes
the agent. Evidence is spent only after the agent’s SVID exists, so a refused
attestation spends nothing. A challenge is a conversation on the same stream;
with no answer inside spiffe.attestationChallengeTimeout, the call fails
DEADLINE_EXCEEDED.
http_challenge is the one place this server dials an address a caller
named. The host name must match spiffe.httpChallengeAllowedDnsPatterns
before it is looked up. That list is empty by default, which refuses every
agent; this is stricter than SPIRE, whose empty list allows any name. In product
mode internal addresses are refused, and in every mode the request follows no
redirect and reads at most 64 bytes.
The registry is the directory
Registration entries live in the LDAP directory under
ou=entries,ou=spiffe and attested agents under ou=agents,ou=spiffe.
Nothing caches them. A form on /admin/spiffe/entries, an ldapmodify and
BatchCreateEntry / BatchUpdateEntry / BatchDeleteEntry are three doors to
one store, all calling the same functions, and the next SVID reflects the
change — an ldapmodify of spiffeX509SvidTtl changes the lifetime of the
next SVID the Workload API hands out.
The two containers hold different kinds of thing. An entry is
configuration deciding what will be issued; an agent is a record of
something that happened, so nothing on it is editable, and ban and delete are
the only controls. What may be changed on an entry is declared rather than
derived: the SPIFFE ID, the parent, the selectors, the lifetimes and the flags
are editable on the forms and the API, and the revision number and the SVID
counter are not — a form that could rewrite a counter would make the page lie
about this service’s own behaviour. ldapmodify still reaches everything.
GET /admin/ldap/spiffe publishes the whole schema, because the directory is
schemaless and roughly thirty of these attribute names are this service’s own
inventions: no registered LDAP schema has a SPIFFE ID or a selector on it.
A workload identity’s directory entry
An accepted SPIFFE credential is an identity, and it reaches the same
authentication record every other protocol family feeds, so its holder appears
on /admin/users and gets a directory entry. Three acceptances count:
| What was presented | Recorded as | Verified |
|---|---|---|
| an X509-SVID over mutual TLS at the SPIRE Server API | X509-SVID (mTLS), once per connection — the credential was accepted at the handshake, so six RPCs on one connection are one authentication |
signature against the trust bundle, validity window, trust-domain match |
| an agent attesting | agent attestation (<type>), for example agent attestation (join token) |
by the node attestor for that type; an unverifiable type is refused, not recorded |
a JWT-SVID at ValidateJWTSVID |
JWT-SVID (validated), once per call, because each is a fresh presentation of a bearer credential |
signature, exp, audience, trust domain |
Being issued an SVID is not one of them: receiving a credential is not
presenting one. A Workload API caller appears on /admin/users with never in
the authentication column, counted under “seen only as a subject” — but it does
get a directory entry.
The entry is uid=spiffe-<12 hex>,ou=users, named by a digest because a SPIFFE
ID is neither a name nor a DN, with the identifier whole on the entry as a
multi-valued spiffeSubject and spiffeTrustDomain / spiffePath beside it.
It is found by spiffeSubject, never by rebuilding the digest, which is
what makes the same identity arriving every way one entry rather than several.
It deliberately does not fold onto a person of a similar name: the last
segment of a SPIFFE path is exactly the kind of short common word (db, web,
api) that collides with a username, and a workload called db is not the DBA.
Every X509-SVID this trust domain mints is written onto that entry too, so
“which identities hold a certificate, what is the current one, and can this one
still get another” is answered by an ldapsearch. The certificate is read back
off itself at the moment it is issued, and the entry carries the same x509*
attributes a verified TLS client certificate writes, in the same strings:
| Attribute | On a SPIFFE identity’s entry |
|---|---|
x509subject |
the SVID’s subject, RFC 4514 — O=SPIRE,C=US by default (spiffe.svidSubject), the same for every SVID in the domain |
x509issuer |
this trust domain’s X.509 authority |
x509serialNumber |
the current SVID’s serial |
x509notBefore / x509notAfter |
its validity window |
x509fingerprint256 |
its SHA-256 fingerprint |
x509svidsIssued |
how many have been minted for this identity |
x509firstIssued / x509lastIssued |
when the first and the most recent were |
- A rotation lands on the same object, because the entry is found by the
SPIFFE ID and by nothing about the certificate: the fiftieth SVID for
spiffe://…/sa/dbupdates the entry the first one created. - The attributes are REPLACED here, where a TLS client certificate APPENDS
them. A renewed client certificate is rare and seeing both serials is the
point; an SVID is minted afresh at half its lifetime for as long as the
workload runs, so appending would add values every hour for ever. The three
counters are what is kept of the history, and the individual serials are all
on
/admin/metrics. - The identity is the SPIFFE ID, not the subject DN. Filing an SVID by its
subject, as a client certificate is filed, would fold every workload in the
trust domain onto one entry called
O=SPIRE. The certificate is a fact about the identity here, not the identity.
spiffeCredentialStatus is not a revocation
SPIFFE has no revocation. There is no CRL, no OCSP and no serial list; the
answer is a short lifetime and rotation, the crl field in the Workload API’s
responses is empty because empty is the conforming value, and an SVID already
in a workload’s hands verifies against the bundle until it expires. Nothing
reads this attribute back, and no certificate is ever refused because of it.
What it records is the three things in the registry that end an identity’s ability to obtain a new credential here, on the entry of the identity they happened to:
| What happened | Status written |
|---|---|
| its last registration entry was deleted — several entries may name one SPIFFE ID, and deleting one of them ends nothing | revoked, with the entry id in the reason |
its agent was banned: AttestAgent and RenewAgent both refuse it |
revoked |
its agent was deleted, so RenewAgent refuses it until it attests again |
revoked |
| a registration entry naming it was created, its agent was unbanned, its agent attested, or an SVID was minted for it anyway | active |
spiffeCredentialStatusReason is a sentence rather than a code, because it is
the only thing that explains a status a reader did not expect. spiffeRevokedAt
is when it was last revoked and is never cleared — the history the
current-state flag deliberately does not keep.
The entry is never removed. An identity this trust domain used to issue
certificates to is exactly what somebody points an LDAP client at the directory
to find, and deleting it would answer “was there ever a workload called db?”
with silence.
Federation
A foreign bundle is pushed in and never fetched. Set it with
POST /admin-api/spiffe/federation-set, the form on /admin/spiffe, or
BatchSetFederatedBundle. RefreshBundle refuses, naming the URL it will not
follow. Federated bundles belong to a realm. None may be named after a trust
domain that any realm of this service serves, and every JWK in one must carry a
use.
The federation specification puts a bundle endpoint URL in the relationship,
and a real implementation polls it. This one records the URL and refuses to
follow it: fetching a URL somebody registered, in order to obtain a key that
will then verify credentials, is a server-side request forgery with a
specification citation attached. It is the same refusal this service gives
WS-Federation’s wreqptr.
What is refused
The list is short, and not empty — a server that said yes to everything would be useless to the only people who call some of these methods:
- A Workload API call with no
workload.spiffe.io: truemetadata header. The Workload Endpoint specification requires it and requires a server to refuse without it (section 3). It is a hardening measure against server-side request forgery rather than an authentication — anybody can send a header, but a request an attacker merely causes a workload to make rarely carries it. A client that omits it has a bug, and this is the only thing that will ever tell them: every real implementation will refuse them, and a server that accepted it would let somebody ship code that works here and nowhere else.spiffe.requireSecurityHeaderturns it off in development only. FetchJWTSVIDandMintJWTSVIDwith no audience. A JWT-SVID is a bearer credential; the audience is what stops one issued for service A being replayed against service B, which is why the specification puts it in the request rather than in configuration.ValidateJWTSVIDon anything that does not really verify — the signature against the trust domain’s JWT authorities,expwith no leeway, the audience, and that thesubbelongs to the trust domain whose key verified it. The point of the call is to be told no.- A registration entry whose SPIFFE ID is invalid, belongs to another trust
domain, or sits under the reserved
/spirepath. AttestAgentfor a banned agent, and a join token this server did not mint, one that has expired, one presented twice, or one minted for a named agent and presented by another. A ban that did not refuse would make the button on/admin/spiffe/agentsa lie, and accepting a join token this server never minted would be accepting a forgery of its own credential.- Every method the caller’s entity is not allowed,
UNAUTHENTICATEDorPERMISSION_DENIEDas above. - An X509-SVID that no authority in this trust domain or a federated one
signed; one outside its validity window (
spiffe.clockSkew); one with no URI subjectAltName, or with several — an SVID has exactly one, and choosing between two would be deciding which identity you have; and one whose SPIFFE ID names a different trust domain from the authority that signed it, which is precisely the cross-domain confusion a bundle exists to prevent. - A federated bundle whose JWKs have no
usemember. A consumer MUST IGNORE a JWK whoseuseis missing or unknown, so a bundle full of them would be stored happily and then verify nothing, with no error anywhere pointing back at the bundle. Refusing it is the only way that failure ever gets diagnosed.
The SPIFFE ID grammar is stricter than a URL parser
A SPIFFE ID is checked as raw text, never with a URL parser, because a URL parser gets four things wrong here, each producing an identifier that looks right in a log:
- A trust domain is lower-case.
spiffe://Example.org/xis not a valid SPIFFE ID, and it is not another spelling ofspiffe://example.org/xeither. A URL parser lower-cases the host for you, which hides the defect: the client that sent the wrong form gets an SVID naming the right one and never learns. - The path is not a URL path. No percent-encoding, no empty segment (so no
trailing slash and no
//), no.or... A URL parser accepts all of those and normalises three of them away. - No port, no userinfo, no query, no fragment. Each is a way of writing an identifier that a naive prefix test treats as belonging to a trust domain it does not — an authorization bug in anything that federates. Membership here is a comparison of the parsed trust domain, never a prefix test.
/spireis reserved for the server and the agents it attests, so a registration entry there is refused: it would be an identifier this service also mints on its own account.
Not implemented
- Six SPIRE Server API methods (listed with reasons on
GET /spiffe) and the two Workload API WIT methods.Bundle.AppendBundleandBundle.PublishJWTAuthoritywould add an authority to this trust domain’s bundle that this server holds no private key for, so every workload would trust an authority that can issue nothing — rotate instead.TrustDomain.RefreshBundlewould fetch a URL somebody registered. The WIT methods (FetchWITSVID,FetchWITBundles,SVID.MintWITSVID,SVID.BatchNewWITSVID,Bundle.PublishWITAuthority) are in the current protos, but the Workload Identity Token’s own format is not settled in a document this service could implement against; minting something JWS-shaped and calling it a WIT-SVID would be inventing a credential format. - Revocation of an SVID. The answer is a short lifetime and rotation, and the
bundle’s
crlfield stays empty. - Workload attestation of a TCP caller, which has no process to attest (so product mode serves TCP only on a declared network).
- In an image signature: the online Rekor lookup for a signature with no bundle (it is refused), the new sigstore bundle format and RFC 3161 timestamps.
- In the Broker API: references to Kubernetes objects other than pods, SPIRE’s cluster pod-reference scope, a Unix socket endpoint and gRPC server reflection.
- An interop run against a real
spire-agent.
Development and product mode
| Product | Development | |
|---|---|---|
| Unmatched Workload API caller | Empty SVID list | An entry is created and an SVID issued while spiffe.autoCreateEntries is on |
Asserted selectors (x-sts-workload-selector) |
Never believed, and spiffe.acceptAssertedSelectors cannot be turned on |
Believed when spiffe.acceptAssertedSelectors is on |
spiffe.attestWorkloads off |
Ignored (logged once, STS-CORE-0106): a caller gets only the entries its selectors match. Turning it off is refused (STS-CORE-0103) |
Every caller is answered with every entry |
| A caller on the SPIRE Server API’s Unix socket | local only when the socket was made 0600 in a directory with no group or other bits (STS-SPIFFE-0117) and SO_PEERCRED says it runs as the service’s own uid (STS-SPIFFE-0118; STS-SPIFFE-0119 without the native module). Anybody else needs an administrator’s X509-SVID on the TCP port |
local on the socket’s existence, while spiffe.trustLocalSocket is on |
| Workload API Unix socket without the native module | Not bound (STS-SPIFFE-0113) |
Served unattested, and GET /spiffe says so |
| Workload API over TCP | Not bound (STS-SPIFFE-0120) unless spiffe.workloadTcpSourceAuthenticated declares the network authenticates source addresses, and never on a wildcard spiffe.grpcHost (STS-SPIFFE-0121) |
Served on spiffe.grpcHost, the wildcard included |
A registration entry selecting only transport: and endpoint:, or nothing |
Refused at every door (STS-SPIFFE-0122); one already stored answers nobody (STS-SPIFFE-0123) |
Accepted, and issued to every caller of that transport |
| Sample registration entries | None | Three, one of them selecting unix:uid:1000 |
http_challenge host |
Internal addresses refused, and the resolved address pinned | Any address the allowed patterns admit |
These are the same in both modes: mutual TLS on the SPIRE Server API, SPIRE’s method table, node attestation, workload attestation where the native module is present, and automatic authority rotation. See what is not checked.
Configuration
SPIFFE has about a hundred settings, grouped below. Every runtime setting can be set per trust realm. The restart settings are fixed for the process at startup. On a realm, the socket settings take effect when that realm’s sockets are reconciled, which happens whenever one of the realm’s settings changes.
The surface and the trust domain
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
spiffe.enabled |
STS_SPIFFE_ENABLED |
true |
yes | Whether the three surfaces answer. On a realm (created off), it builds that realm’s authorities and binds its sockets. |
spiffe.trustDomain |
STS_SPIFFE_TRUST_DOMAIN |
example.org |
restart (per realm) | The trust domain; lower-case letters, digits, dots, dashes and underscores. A realm defaults to its DNS domain. |
spiffe.bundlePath |
STS_SPIFFE_BUNDLE_PATH |
/spiffe/bundle |
restart | Where the trust bundle is published. |
spiffe.refreshHint |
STS_SPIFFE_REFRESH_HINT |
300 |
yes | The spiffe_refresh_hint in the bundle, in seconds. |
spiffe.maxFederatedBundles |
STS_SPIFFE_MAX_FEDERATED_BUNDLES |
32 |
yes | How many foreign trust domains’ bundles a realm holds. |
Authorities, keys and lifetimes
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
spiffe.x509KeyType |
STS_SPIFFE_X509_KEY_TYPE |
ec-p256 |
restart | The key type of every X509-SVID, and of the self-signed fallback authority. |
spiffe.jwtKeyType |
STS_SPIFFE_JWT_KEY_TYPE |
ec-p256 |
restart | The JWT authority’s key, which sets every JWT-SVID’s alg (ES256, ES384, ES512 or RS256). |
spiffe.caTtl |
STS_SPIFFE_CA_TTL |
86400 |
restart | How long the X.509 authority’s certificate is valid; no SVID outlives it. |
spiffe.svidTtl |
STS_SPIFFE_SVID_TTL |
3600 |
yes | The default X509-SVID lifetime; an entry’s own x509SvidTtl wins. |
spiffe.jwtSvidTtl |
STS_SPIFFE_JWT_SVID_TTL |
300 |
yes | The default JWT-SVID lifetime. |
spiffe.agentSvidTtl |
STS_SPIFFE_AGENT_SVID_TTL |
0 |
yes | The lifetime of an agent’s SVID from AttestAgent and RenewAgent; 0 means spiffe.svidTtl. |
spiffe.svidSubject |
STS_SPIFFE_SVID_SUBJECT |
C=US,O=SPIRE |
yes | The X.501 subject of every X509-SVID, SPIRE’s own by default; it cannot be empty. |
spiffe.caSubject |
STS_SPIFFE_CA_SUBJECT |
CN=sts SPIFFE {kind} ({trustDomain}),O=sts |
yes | The subject of the self-signed fallback authority and of every downstream CA. |
spiffe.retainedAuthorities |
STS_SPIFFE_RETAINED_AUTHORITIES |
4 |
yes | How many self-signed X.509 and JWT authorities a rotation keeps in the bundle (at least 2). |
spiffe.clockSkew |
STS_SPIFFE_CLOCK_SKEW |
60 |
yes | How far out a caller’s clock may be when its X509-SVID’s validity is checked. |
Sockets
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
spiffe.workloadSocketEnabled |
STS_SPIFFE_WORKLOAD_SOCKET_ENABLED |
true |
restart | Whether the Workload API is served on a Unix socket. |
spiffe.workloadSocket |
STS_SPIFFE_WORKLOAD_SOCKET |
/tmp/spire-agent/public/api.sock |
restart | The Workload API’s socket path, SPIRE’s own default. |
spiffe.workloadPort |
STS_SPIFFE_WORKLOAD_PORT |
8092 |
restart | The Workload API over TCP; 0 turns it off. A new realm is seeded with 0. Not bound in product mode without the next row. |
spiffe.workloadTcpSourceAuthenticated |
STS_SPIFFE_WORKLOAD_TCP_SOURCE_AUTHENTICATED |
false |
restart | Product mode only: declares that the network authenticates source addresses, so the Workload API is served over TCP, on a named spiffe.grpcHost. Warning: whoever holds an address an entry selects is issued its SVIDs. |
spiffe.serverPort |
STS_SPIFFE_SERVER_PORT |
8181 |
restart | The SPIRE Server API over TCP (mutual TLS); 0 turns it off. A new realm is seeded with 0. |
spiffe.serverSocketEnabled |
STS_SPIFFE_SERVER_SOCKET_ENABLED |
false |
restart | Whether the SPIRE Server API is also served on a Unix socket. |
spiffe.serverSocket |
STS_SPIFFE_SERVER_SOCKET |
/tmp/spire-server/private/api.sock |
restart | That socket’s path, SPIRE’s own default. |
spiffe.brokerPort |
STS_SPIFFE_BROKER_PORT |
0 |
restart | The SPIFFE Broker API over TCP (mutual TLS); 0, the default and a new realm’s seed, binds nothing. |
spiffe.brokers |
STS_SPIFFE_BROKERS |
(empty) | yes | The brokers, <SPIFFE ID>=<types> separated by spaces, the types from pid, k8s and *; managed on /admin/spiffe/brokers. Warning: allow pid only to a broker on this host. |
spiffe.grpcHost |
STS_SPIFFE_GRPC_HOST |
0.0.0.0 |
restart | The address every TCP gRPC listener binds; each realm needs an address of its own. |
The Workload API
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
spiffe.requireSecurityHeader |
STS_SPIFFE_REQUIRE_SECURITY_HEADER |
true |
yes | Refuse a call without workload.spiffe.io: true, as the specification requires. Off is development mode only: product always requires the header and refuses turning it off (#181). |
spiffe.attestWorkloads |
STS_SPIFFE_ATTEST_WORKLOADS |
true |
yes | Answer a caller only with the entries its selectors match; off answers every caller with every entry, in development only. |
spiffe.autoCreateEntries |
STS_SPIFFE_AUTOCREATE_ENTRIES |
true |
yes | In development, create an entry for a caller that matches none; off gives it an empty SVID list. |
spiffe.acceptAssertedSelectors |
STS_SPIFFE_ACCEPT_ASSERTED_SELECTORS |
false |
yes | In development, believe selectors a caller sends in x-sts-workload-selector. Nothing verifies them. Refused in product. |
spiffe.maxEntries |
STS_SPIFFE_MAX_ENTRIES |
500 |
yes | How many registration entries may live under ou=spiffe; past it a new one is refused. |
Workload attestors
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
spiffe.workloadAttestors |
STS_SPIFFE_WORKLOAD_ATTESTORS |
unix |
yes | Which of unix, docker, k8s and systemd run for a Unix-socket connection and a Broker API process reference. systemd needs the optional package dbus-next. |
spiffe.workloadProcRoot |
STS_SPIFFE_WORKLOAD_PROC_ROOT |
/proc |
yes | Where a caller’s process is read from. |
spiffe.unixDiscoverWorkloadPath |
STS_SPIFFE_UNIX_DISCOVER_WORKLOAD_PATH |
false |
yes | Add path: and sha256: selectors for the caller’s executable. |
spiffe.unixWorkloadSizeLimit |
STS_SPIFFE_UNIX_WORKLOAD_SIZE_LIMIT |
0 |
yes | 0 hashes any size, a positive value refuses a larger executable, and -1 emits no sha256:. |
spiffe.dockerSocketPath |
STS_SPIFFE_DOCKER_SOCKET_PATH |
unix:///var/run/docker.sock |
yes | The Docker Engine asked about the caller’s container. |
spiffe.dockerApiVersion |
STS_SPIFFE_DOCKER_API_VERSION |
(empty) | yes | The Engine API version; empty uses the Engine’s default. |
spiffe.dockerPodmanSocketPath |
STS_SPIFFE_DOCKER_PODMAN_SOCKET_PATH |
unix:///run/podman/podman.sock |
yes | docker: the rootful Podman API socket, asked when a container’s cgroup says libpod (#170). |
spiffe.dockerPodmanSocketPathTemplate |
STS_SPIFFE_DOCKER_PODMAN_SOCKET_PATH_TEMPLATE |
unix:///run/user/%d/podman/podman.sock |
yes | docker: the rootless Podman socket; %d is the uid of the container’s user-<uid>.slice, exactly once. |
spiffe.dockerUseRootlessPodman |
STS_SPIFFE_DOCKER_USE_ROOTLESS_PODMAN |
false |
yes | docker: attest rootless Podman containers. WARNING: that socket is in the caller’s own runtime directory and answers what the caller likes; pair its entries with unix:uid or unix:user. Off, a rootless container gets no docker selectors. |
spiffe.dockerSigstoreEnabled |
STS_SPIFFE_DOCKER_SIGSTORE_ENABLED |
false |
yes | docker: require a cosign image signature that verifies, with its Rekor bundle, and add SPIRE’s image-signature… selectors. A signature that does not verify refuses the connection (#170). |
spiffe.dockerSigstorePublicKeyFiles |
STS_SPIFFE_DOCKER_SIGSTORE_PUBLIC_KEY_FILES |
(empty) | yes | docker sigstore: cosign public key FILES (ECDSA, RSA, Ed25519, ML-DSA, SLH-DSA, composite). |
spiffe.dockerSigstoreTrustedRootFile |
STS_SPIFFE_DOCKER_SIGSTORE_TRUSTED_ROOT_FILE |
(empty) | yes | docker sigstore: a pinned sigstore trusted_root.json FILE — Fulcio CAs, Rekor and CT log keys — used while TUF is off. |
spiffe.dockerSigstoreAllowedIdentities |
STS_SPIFFE_DOCKER_SIGSTORE_ALLOWED_IDENTITIES |
(empty) | yes | docker sigstore: issuer=subject pairs a keyless signer must match (regular expressions where they hold one of SPIRE’s characters, unanchored as cosign’s). Empty admits no keyless signer. |
spiffe.dockerSigstoreSkippedImages |
STS_SPIFFE_DOCKER_SIGSTORE_SKIPPED_IMAGES |
(empty) | yes | docker sigstore: repository digests attested without verification. |
spiffe.dockerSigstoreAllowedRegistries |
STS_SPIFFE_DOCKER_SIGSTORE_ALLOWED_REGISTRIES |
(empty) | yes | docker sigstore: the registry hosts (and token realms, and blob redirect hosts) signatures are fetched from; the image names the registry, so none other is dialled. Empty refuses every registry. Docker Hub is index.docker.io. |
spiffe.dockerSigstoreRegistryAuthFile |
STS_SPIFFE_DOCKER_SIGSTORE_REGISTRY_AUTH_FILE |
(empty) | yes | docker sigstore: a Docker config.json FILE of registry credentials; empty is anonymous. |
spiffe.dockerSigstoreSkipTlog |
STS_SPIFFE_DOCKER_SIGSTORE_SKIP_TLOG |
false |
yes | docker sigstore: skip the Rekor transparency log. WARNING: a signature never logged — a stolen key’s, or a keyless one past its certificate — then verifies. |
spiffe.dockerSigstoreIgnoreSct |
STS_SPIFFE_DOCKER_SIGSTORE_IGNORE_SCT |
false |
yes | docker sigstore: do not require the keyless certificate’s embedded SCT. WARNING: a Fulcio certificate never logged is then accepted. |
spiffe.dockerSigstoreIgnoreAttestations |
STS_SPIFFE_DOCKER_SIGSTORE_IGNORE_ATTESTATIONS |
false |
yes | docker sigstore: do not require in-toto attestations; off (SPIRE’s default) refuses an image that has none. |
spiffe.dockerSigstoreTufUrl |
STS_SPIFFE_DOCKER_SIGSTORE_TUF_URL |
https://tuf-repo-cdn.sigstore.dev |
yes (per process) | docker sigstore: the TUF repository the trust root is refreshed from. |
spiffe.dockerSigstoreTufRootFile |
STS_SPIFFE_DOCKER_SIGSTORE_TUF_ROOT_FILE |
(empty) | yes (per process) | docker sigstore: the TUF root.json FILE first trusted; empty turns TUF off. A refresh that fails keeps the last verified set. |
spiffe.dockerSigstoreTufRefreshS |
STS_SPIFFE_DOCKER_SIGSTORE_TUF_REFRESH_S |
86400 |
yes (per process) | docker sigstore: how often the scheduler job spiffe.sigstore-tuf-refresh runs; 0 is off. |
spiffe.k8sKubeletReadOnlyPort |
STS_SPIFFE_K8S_KUBELET_READ_ONLY_PORT |
0 |
yes | Above 0, read the pod list over plain HTTP on loopback instead of the secure port. |
spiffe.k8sKubeletSecurePort |
STS_SPIFFE_K8S_KUBELET_SECURE_PORT |
0 |
yes | The kubelet’s secure port to dial; 0 is 10250. |
spiffe.k8sNodeName |
STS_SPIFFE_K8S_NODE_NAME |
(empty) | yes | The kubelet host; empty reads the variable named by the next setting. |
spiffe.k8sNodeNameEnv |
STS_SPIFFE_K8S_NODE_NAME_ENV |
MY_NODE_NAME |
yes | The environment variable holding the node name. |
spiffe.k8sCertificateFile |
STS_SPIFFE_K8S_CERTIFICATE_FILE |
(empty) | yes | A client certificate file for the kubelet; empty uses a token. |
spiffe.k8sPrivateKeyFile |
STS_SPIFFE_K8S_PRIVATE_KEY_FILE |
(empty) | yes | That certificate’s private key file. |
spiffe.k8sUseAnonymousAuthentication |
STS_SPIFFE_K8S_USE_ANONYMOUS_AUTHENTICATION |
false |
yes | Present no token and no certificate on the secure port. |
spiffe.k8sTokenFile |
STS_SPIFFE_K8S_TOKEN_FILE |
(empty) | yes | The token file; empty is the in-cluster service account’s. |
spiffe.k8sSkipKubeletVerification |
STS_SPIFFE_K8S_SKIP_KUBELET_VERIFICATION |
false |
yes | Do not verify the kubelet’s certificate. Development only (#171): ignored in product mode, and refused on write there. |
spiffe.k8sKubeletCaFile |
STS_SPIFFE_K8S_KUBELET_CA_FILE |
(empty) | yes | The kubelet’s CA file; empty is the service account’s ca.crt. |
spiffe.k8sMaxPollAttempts |
STS_SPIFFE_K8S_MAX_POLL_ATTEMPTS |
60 |
yes | How often the pod list is read before a missing container fails attestation. |
spiffe.k8sPollRetryIntervalMs |
STS_SPIFFE_K8S_POLL_RETRY_INTERVAL_MS |
500 |
yes | The wait between pod list reads. |
spiffe.k8sDisableContainerSelectors |
STS_SPIFFE_K8S_DISABLE_CONTAINER_SELECTORS |
false |
yes | Omit the container selectors. |
spiffe.k8sEnableNamespaceLabels |
STS_SPIFFE_K8S_ENABLE_NAMESPACE_LABELS |
false |
yes | Add ns-label: selectors, read from the API server. |
The SPIRE Server API
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
spiffe.trustLocalSocket |
STS_SPIFFE_TRUST_LOCAL_SOCKET |
true |
yes | Trust the server’s Unix socket as the local entity — in product only for a caller running as the service’s uid on a socket verified private; off demands an X509-SVID there too. |
spiffe.adminIds |
STS_SPIFFE_ADMIN_IDS |
(empty) | yes | SPIFFE IDs that are administrators, SPIRE’s admin_ids; no entry is needed. Empty on a new realm. |
spiffe.maxPageSize |
STS_SPIFFE_MAX_PAGE_SIZE |
1000 |
yes | The cap on page_size for every List* method. |
spiffe.maxRecordedConnections |
STS_SPIFFE_MAX_RECORDED_CONNECTIONS |
512 |
yes | How many connections are remembered so that an SVID is one authentication per connection. |
spiffe.maxAgents |
STS_SPIFFE_MAX_AGENTS |
200 |
yes | How many attested agents are held; past it the oldest is dropped. |
Node attestation: common settings
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
spiffe.nodeAttestors |
STS_SPIFFE_NODE_ATTESTORS |
join_token |
yes | The attestation types AttestAgent accepts in this realm. |
spiffe.attestationChallengeTimeout |
STS_SPIFFE_ATTESTATION_CHALLENGE_TIMEOUT |
30 |
yes | How long AttestAgent waits for a challenge response. |
spiffe.joinTokenTtl |
STS_SPIFFE_JOIN_TOKEN_TTL |
600 |
yes | A join token’s lifetime when the request names none. |
spiffe.maxJoinTokens |
STS_SPIFFE_MAX_JOIN_TOKENS |
256 |
yes | How many unspent join tokens a realm holds; at the cap a new one is refused RESOURCE_EXHAUSTED. |
x509pop, sshpop and tpm_devid
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
spiffe.x509popMode |
STS_SPIFFE_X509POP_MODE |
external_pki |
yes | Verify against spiffe.x509popCaBundle, or (spiffe) against the realm’s own SPIFFE bundle. |
spiffe.x509popCaBundle |
STS_SPIFFE_X509POP_CA_BUNDLE |
(empty) | yes | PEM anchors an agent’s certificate must chain to; empty refuses every x509pop agent. |
spiffe.x509popSpiffePrefix |
STS_SPIFFE_X509POP_SPIFFE_PREFIX |
/spire-exchange/ |
yes | In spiffe mode, the path prefix the agent’s SVID must carry. |
spiffe.x509popAgentPathTemplate |
STS_SPIFFE_X509POP_AGENT_PATH_TEMPLATE |
(empty) | yes | SPIRE’s agent_path_template; empty is SPIRE’s default for the mode. |
spiffe.x509popMaxIntermediates |
STS_SPIFFE_X509POP_MAX_INTERMEDIATES |
4 |
yes | The most intermediate certificates an attestation may carry. |
spiffe.x509popMaxRsaKeySize |
STS_SPIFFE_X509POP_MAX_RSA_KEY_SIZE |
8192 |
yes | The largest RSA key accepted on any presented certificate. |
spiffe.x509popVerifyClientIp |
STS_SPIFFE_X509POP_VERIFY_CLIENT_IP |
false |
yes | Require the agent’s address to be one of the leaf’s IP subjectAltNames. |
spiffe.x509popGroupTemplate |
STS_SPIFFE_X509POP_GROUP_TEMPLATE |
(empty) | yes | SPIRE’s group_template, producing an x509pop:group: selector. |
spiffe.x509popAllowedGroups |
STS_SPIFFE_X509POP_ALLOWED_GROUPS |
(empty) | yes | The only values the group template may produce a selector for. |
spiffe.sshpopCertAuthorities |
STS_SPIFFE_SSHPOP_CERT_AUTHORITIES |
(empty) | yes | SSH CA public keys, one per line; empty refuses every sshpop agent. |
spiffe.sshpopCanonicalDomain |
STS_SPIFFE_SSHPOP_CANONICAL_DOMAIN |
(empty) | yes | The domain the host certificate’s first principal must end in. |
spiffe.sshpopAgentPathTemplate |
STS_SPIFFE_SSHPOP_AGENT_PATH_TEMPLATE |
(empty) | yes | SPIRE’s agent_path_template for sshpop. |
spiffe.sshpopVerifyClientIp |
STS_SPIFFE_SSHPOP_VERIFY_CLIENT_IP |
false |
yes | Require a source-address option naming the agent’s address. |
spiffe.tpmDevidCaBundle |
STS_SPIFFE_TPM_DEVID_CA_BUNDLE |
(empty) | yes | PEM anchors for DevID certificates; empty refuses every tpm_devid agent. |
spiffe.tpmEndorsementCaBundle |
STS_SPIFFE_TPM_ENDORSEMENT_CA_BUNDLE |
(empty) | yes | PEM certificates of trusted TPM manufacturers; empty refuses every tpm_devid agent. |
k8s_psat and http_challenge
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
spiffe.k8sPsatClusters |
STS_SPIFFE_K8S_PSAT_CLUSTERS |
(empty) | yes | SPIRE’s clusters, as JSON from cluster name to its allow list, audience and API server; empty refuses every k8s_psat agent. |
spiffe.httpChallengeAllowedDnsPatterns |
STS_SPIFFE_HTTP_CHALLENGE_ALLOWED_DNS_PATTERNS |
(empty) | yes | Regular expressions an agent’s host name must match before it is dialled; empty refuses every agent. |
spiffe.httpChallengeRequiredPort |
STS_SPIFFE_HTTP_CHALLENGE_REQUIRED_PORT |
0 |
yes | The port the challenge must be served on; 0 allows any. |
spiffe.httpChallengeAllowNonRootPorts |
STS_SPIFFE_HTTP_CHALLENGE_ALLOW_NON_ROOT_PORTS |
true |
yes | Off accepts only a privileged port. |
spiffe.httpChallengeTofu |
STS_SPIFFE_HTTP_CHALLENGE_TOFU |
true |
yes | A host name attests once until its agent is deleted. |
spiffe.httpChallengeVerifyClientIp |
STS_SPIFFE_HTTP_CHALLENGE_VERIFY_CLIENT_IP |
false |
yes | Require the agent’s address to be one its host name resolves to. |
aws_iid
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
spiffe.awsIidPartition |
STS_SPIFFE_AWS_IID_PARTITION |
aws |
yes | The partition used to build the assume-role ARN. |
spiffe.awsIidAssumeRole |
STS_SPIFFE_AWS_IID_ASSUME_ROLE |
(empty) | yes | A role name assumed in each node’s account; empty uses the SDK credential chain. |
spiffe.awsIidSkipBlockDevice |
STS_SPIFFE_AWS_IID_SKIP_BLOCK_DEVICE |
false |
yes | Skip the check that the root volume and first interface were attached together. |
spiffe.awsIidDisableInstanceProfileSelectors |
STS_SPIFFE_AWS_IID_DISABLE_INSTANCE_PROFILE_SELECTORS |
false |
yes | Emit no iamrole: selectors and make no IAM call. |
spiffe.awsIidLocalValidAccountIds |
STS_SPIFFE_AWS_IID_LOCAL_VALID_ACCOUNT_IDS |
(empty) | yes | SPIRE’s account_ids_for_local_validation. |
spiffe.awsIidAgentPathTemplate |
STS_SPIFFE_AWS_IID_AGENT_PATH_TEMPLATE |
(empty) | yes | SPIRE’s agent_path_template for aws_iid. |
spiffe.awsIidVerifyOrganization |
STS_SPIFFE_AWS_IID_VERIFY_ORGANIZATION |
(empty) | yes | JSON requiring the node’s account to be an active member of an AWS Organization or a list. |
spiffe.awsIidEksClusterNames |
STS_SPIFFE_AWS_IID_EKS_CLUSTER_NAMES |
(empty) | yes | EKS clusters one of whose node groups the instance must be in. |
spiffe.awsIidEndpoint |
STS_SPIFFE_AWS_IID_ENDPOINT |
(empty) | yes | An endpoint every AWS client is pointed at instead of the public one. |
gcp_iit
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
spiffe.gcpIitProjectIdAllowList |
STS_SPIFFE_GCP_IIT_PROJECT_ID_ALLOW_LIST |
(empty) | yes | The projects whose tokens are accepted; empty refuses every gcp_iit agent. |
spiffe.gcpIitAgentPathTemplate |
STS_SPIFFE_GCP_IIT_AGENT_PATH_TEMPLATE |
(empty) | yes | SPIRE’s agent_path_template for gcp_iit. |
spiffe.gcpIitUseInstanceMetadata |
STS_SPIFFE_GCP_IIT_USE_INSTANCE_METADATA |
false |
yes | Read the instance from the Compute Engine API for tag:, label: and metadata: selectors. |
spiffe.gcpIitAllowedLabelKeys |
STS_SPIFFE_GCP_IIT_ALLOWED_LABEL_KEYS |
(empty) | yes | SPIRE’s allowed_label_keys. |
spiffe.gcpIitAllowedMetadataKeys |
STS_SPIFFE_GCP_IIT_ALLOWED_METADATA_KEYS |
(empty) | yes | SPIRE’s allowed_metadata_keys. |
spiffe.gcpIitMaxMetadataValueSize |
STS_SPIFFE_GCP_IIT_MAX_METADATA_VALUE_SIZE |
128 |
yes | A longer allowed metadata value refuses the attestation. |
spiffe.gcpIitServiceAccountFile |
STS_SPIFFE_GCP_IIT_SERVICE_ACCOUNT_FILE |
(empty) | yes | A service account file; empty uses application default credentials. |
spiffe.gcpIitCertsUrl |
STS_SPIFFE_GCP_IIT_CERTS_URL |
https://www.googleapis.com/oauth2/v1/certs |
yes | Where Google publishes the token signing certificates. |
azure_imds
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
spiffe.azureImdsTenants |
STS_SPIFFE_AZURE_IMDS_TENANTS |
(empty) | yes | SPIRE’s tenants, as JSON from tenant domain to its settings; empty refuses every azure_imds agent. |
spiffe.azureImdsAgentPathTemplate |
STS_SPIFFE_AZURE_IMDS_AGENT_PATH_TEMPLATE |
(empty) | yes | SPIRE’s agent_path_template for azure_imds. |
spiffe.azureImdsAllowedMetadataDomains |
STS_SPIFFE_AZURE_IMDS_ALLOWED_METADATA_DOMAINS |
metadata.azure.com |
yes | Domains the attested document’s signing certificate must name. |
spiffe.azureImdsTrustBundle |
STS_SPIFFE_AZURE_IMDS_TRUST_BUNDLE |
(empty) | yes | Extra CA certificates trusted beside the embedded DigiCert roots. |
spiffe.azureImdsIntermediateHost |
STS_SPIFFE_AZURE_IMDS_INTERMEDIATE_HOST |
www.microsoft.com |
yes | The only host the signing certificate’s CA Issuers URL may name. |
spiffe.azureImdsDiscoveryUrl |
STS_SPIFFE_AZURE_IMDS_DISCOVERY_URL |
https://login.microsoftonline.com |
yes | Where a tenant domain’s ID is looked up. |
These tables are a copy of the rows in the service’s settings table. The live
source is Protocols → SPIFFE (/admin/spiffe), where every setting is
drawn, and GET /admin-api/config. POST /admin-api/config/set changes one.
See Configuration for how a value is resolved.
Design decisions
- A realm is told apart by address, not by path. gRPC’s path is the method name, fixed by the specifications, so a realm segment would be a method no client calls. A real deployment is the same: one SPIRE server is one trust domain.
- A realm starts with SPIFFE off. Turning SPIFFE on binds sockets and creates an authority. Creating a realm should not open ports or start issuing credentials nobody asked for.
- A new realm gets no TCP gRPC ports and no administrators. The inherited ports and address are already the default realm’s. The network-reachable API that mints credentials is opened only by somebody who sets an address and ports on the realm.
- The two gRPC surfaces are authenticated differently because their specifications say opposite things. The Workload API must not authenticate its caller. A SPIRE server authorizes every method against what the caller is. Neither half is a setting.
- SPIRE’s authorization table is copied, not reasoned out. A table derived from what each method “obviously” needs would differ from SPIRE in a few places, and a client author could not tell which end was wrong.
- Attestation is not authentication. The Unix socket attests the connecting process as an agent does. A TCP caller, which cannot be attested, is identified by selectors spelt so they cannot pass for attested facts.
- Node attestation refuses what it cannot verify, in every mode. A real agent pointed at a server that accepted any attestation type could join a trust domain with an invented one.
- No credential is a setting. Settings are shown on the console, returned by the management API and persisted. Cloud keys and tokens come from files or from the SDK’s own credential chain.
- A foreign bundle is pushed, never fetched. Fetching a URL somebody
registered, to obtain a key that will verify credentials, is a server-side
request forgery.
http_challengeis the one exception, and its allow list is empty by default. - Federated bundles belong to a realm, and none may shadow a served domain. Otherwise one realm could register a bundle under another realm’s trust domain and mint SVIDs that the other realm would accept.
- The registry is the directory. Entries are configuration an
ldapmodifycan change, with no cache to go stale. Agents are records, so they are not editable. - SPIFFE has no revocation, and nothing here pretends to.
spiffeCredentialStatusrecords who can no longer be issued an SVID. Short lifetimes and rotation are the mechanism. The streams re-send at half the lifetime, so clients exercise rotation. - An unregistered workload gets an empty SVID list in product mode, and in
development with
spiffe.autoCreateEntriesoff, because that is what a real agent answers. It is the only way to test a client’s “I have no identity” path. - A join token is stored only as a digest, and its selector is
token-sha256:, so neither the store nor the directory holds a usable token. - X.509 is EC P-256 by default, which is what SPIRE issues and what the
X509-SVID specification recommends. The certificate code that makes it is
shared, byte-identical, with the OAuth2/OIDC Debugger (
node-forge, used elsewhere here, cannot sign with an EC key at all), and it comes with that project’s test, which builds about 240 certificates — every key algorithm against every signature algorithm, every extension, a four-deep chain — and checks each with OpenSSL rather than by reading back what the same code wrote. - gRPC is
@grpc/grpc-js, not a hand-rolled codec. A protobuf codec and a gRPC server over node’shttp2would have been about 900 lines and no dependency, but this service exists to be talked to by real clients —go-spiffe,spiffe-helper, a SPIRE agent, thespire-serverCLI — and an interoperability bug in a hand-rolled HTTP/2 framer appears as a client that hangs or reports a truncated message, with no way for the client author to tell whose fault it is. About thirty packages buy the wire being right. Two traps in it: the loader’skeepCase: truedoes not reach the well-known types, so agoogle.protobuf.Structhas to be built withstringValue(string_valueserialises to nothing, silently); and protobufjs converts onlyAny, so a plain object assigned to aStructfield becomes an empty Struct.
In the running service
- Protocols → SPIFFE → SPIFFE (
/admin/spiffe): the trust domain, the X.509 and JWT authorities and the trust anchors, whether each of the four sockets actually bound, how Workload API callers are identified and attested, the federated bundles, and everyspiffe.*setting. It has Rotate (X.509, JWT or both) and a form to set or remove a federated bundle. - Protocols → SPIFFE → Registration entries (
/admin/spiffe/entries): which workload gets which SPIFFE ID and what an SVID from each entry carries: parent, selectors, DNS names, federates-with, the two lifetimes, hint, expiry, and theadmin,downstreamandstoreSvidflags. Create, edit and delete are here. A change takes effect on the next SVID. - Protocols → SPIFFE → Agents (
/admin/spiffe/agents): every agent that has attested, what it was given and when. Ban, unban and delete are the only controls. Deleting an agent lets trust-on-first-use evidence attest again. - Directory → SPIFFE entries (
/admin/ldap/spiffe): both containers as the directory holds them, with their attribute schema. GET /spiffe(add?format=json): all three surfaces, the state of every socket, every SPIRE method with its authorization row, the six unimplemented methods with their reasons, the node attestors this build verifies, and whether the Workload API socket is attested.- The management API:
GET /admin-api/spiffe,POST /admin-api/spiffe/{action}(rotate,federation-set,federation-remove),/admin-api/spiffe/entriesand/admin-api/spiffe/agentswith their actions.
GET /admin/sts-metadata cannot list the gRPC sockets, because they register no
HTTP route. GET /spiffe and /admin/spiffe report them. Every SPIFFE failure
is recorded under an STS-SPIFFE-NNNN code on the audit row and the log line,
and never sent to a client. See error codes.
Related
- PKI: the Root, the realm Intermediate and the SPIFFE Issuing CA
- LDAP: the registry under
ou=spiffeand the workload entries underou=users - Trust realms
- TLS and mutual TLS
- XACML: the access gate after SPIRE’s table
- Sessions
- What is not checked, especially The Workload API is the opposite case
- Configuration