Skip to main content

core.security::spiffe — workload identity

What is SPIFFE and why does it matter?

The Secure Production Identity Framework For Everyone (SPIFFE) is the industry-standard answer to a deceptively simple question: "what identity is this workload?"

In cloud-native deployments, the old model — IP-address firewalls, long-lived service accounts, mutual-TLS-with-certificates — breaks down at scale:

  • Pods are ephemeral; their IPs churn.
  • Kubernetes service accounts can only be granted cluster-local scope.
  • Hand-managed cert rotation is error-prone; cert expiry outages are famous (Microsoft Azure, Slack, GitHub, …).
  • Multi-cluster, multi-cloud fleets need a unified identity namespace.

SPIFFE solves this with:

  1. SPIFFE ID — a URI naming a workload uniformly across infrastructure: spiffe://<trust-domain>/<path>.
  2. SVID — the cryptographic credential attesting the ID. Two flavours: X.509-SVID (certificates) and JWT-SVID (bearer tokens).
  3. Workload API — a local Unix-domain-socket agent (typically SPIRE) that hands out short-lived SVIDs and rotates them automatically.
  4. Trust bundles — the public keys used to verify SVIDs from peer workloads.

The three-part promise

  1. Short-lived credentials — SVIDs typically valid for 1 hour, rotated at the 30-minute mark. Lost keys become worthless fast.
  2. Zero ambient authority — a process that wants an identity must attach to the local workload-API socket, proving its kernel-attested identity (UID, executable path, K8s pod metadata). The agent then issues an SVID.
  3. Cross-platform — identities federate across K8s clusters, VMs, bare-metal, serverless. One protocol; spiffe://prod/service has a stable meaning regardless of whether the workload runs on AWS EKS, GKE, a VM, or a bare-metal rack.

Verum's integration

Three files in core/security/spiffe/:

FileRole
id.vrSpiffeId type — parse, validate, render spiffe://... URIs
svid.vrX509Svid, JwtSvid, trust bundles — typed credentials + verifier state
workload_api.vrSPIRE Workload API client — fetches SVIDs, streams rotation updates

Plus a higher-level Weft middleware (net/weft/spiffe.vr — not in this subtree) that wraps these into an HTTP authentication layer.


SpiffeId — the identity

Shape

mount core.security.spiffe.id.{SpiffeId, SpiffeIdError};

public type SpiffeId is {
trust_domain: Text, // DNS-like, e.g. "prod.example.com"
path: Text, // path component, e.g. "/ns/team/sa/billing"
};

A SPIFFE ID URI looks like:

spiffe://prod.example.com/ns/billing/sa/api-gateway
\__________________/\____________________/
trust domain path

API

impl SpiffeId {
// Construction
pub fn new(trust_domain: Text, path: Text) -> Result<SpiffeId, SpiffeIdError>;
pub fn from_trust_domain(trust_domain: Text) -> Result<SpiffeId, SpiffeIdError>;
pub fn parse(input: &Text) -> Result<SpiffeId, SpiffeIdError>;

// Accessors
pub fn trust_domain(&self) -> &Text;
pub fn path(&self) -> &Text;
pub fn is_trust_domain_id(&self) -> Bool; // true iff no path component

// Rendering
pub fn to_uri(&self) -> Text; // "spiffe://td/path"

// Queries
pub fn is_member_of(&self, trust_domain: &Text) -> Bool;
}

Validation rules (from the SPIFFE spec)

Trust domain:

  • 1–255 bytes.
  • Only a-z, 0-9, -, ., _.
  • Case-insensitive but canonically lowercase.
  • No leading / trailing dots; no two consecutive dots.

Path:

  • Begins with / (or is empty for trust-domain-only IDs).
  • Each segment is URL-safe: a-z, A-Z, 0-9, -, ., _, ~, sub-delims, :, @, %.
  • Total length ≤ 2048 bytes.

Invalid input returns a typed error:

public type SpiffeIdError is
| Empty
| MissingScheme
| InvalidScheme(Text)
| EmptyTrustDomain
| InvalidTrustDomain { reason: Text }
| PathTooLong { len: Int }
| InvalidCharacters { at: Int, character: Byte };

Quick example

use core.security.spiffe.id.{SpiffeId};

let id = SpiffeId.parse(&"spiffe://prod.example.com/ns/billing/sa/api")?;
assert_eq!(id.trust_domain(), &"prod.example.com");
assert_eq!(id.path(), &"/ns/billing/sa/api");
assert!(id.is_member_of(&"prod.example.com".to_string()));

SVIDs — credentials

Two flavours, each with a matching trust bundle type.

X509Svid — certificate-based

// svid.vr reuses the TLS stack's certificate and key types rather
// than defining its own DER-blob variants — one parser, one audit.
mount core.net.tls.{Certificate, PrivateKey};

public type X509Svid is {
/// SPIFFE ID bound in the leaf cert's URI SAN.
id: SpiffeId,
/// Leaf cert first, followed by the intermediate chain.
cert_chain: List<Certificate>,
/// Private key for the leaf cert (ECDSA-P256 or Ed25519 typically).
/// SENSITIVE — wipe on drop.
private_key: PrivateKey,
/// NotAfter of the leaf cert.
expires_at: Instant,
};

impl X509Svid {
pub fn new(id: SpiffeId, cert_chain: List<Certificate>,
private_key: PrivateKey, expires_at: Instant) -> X509Svid;
pub fn id(&self) -> &SpiffeId;
pub fn cert_chain(&self) -> &List<Certificate>;
pub fn private_key(&self) -> &PrivateKey;
pub fn expires_at(&self) -> Instant;
pub fn is_expired(&self, now: Instant) -> Bool;
}

public type X509Bundle is {
trust_domain: Text,
/// CA certs authorised to sign peer SVIDs for this trust domain.
cas: List<Certificate>,
};

impl X509Bundle {
pub fn new(trust_domain: Text, cas: List<Certificate>) -> X509Bundle;
pub fn trust_domain(&self) -> &Text;
pub fn cas(&self) -> &List<Certificate>;
}

public type X509BundleSet is {
/// One entry per trust domain — federated deployments hold many;
/// single-domain deployments hold exactly one.
bundles: List<X509Bundle>,
};

Use case — mutual TLS with SPIFFE identity in the cert SAN. The TLS handshake's client-cert is an X509Svid; the server verifies it against its X509BundleSet. Peer identity = SPIFFE URI SAN in the verified cert.

JwtSvid — token-based

public type JwtSvid is {
/// Subject SPIFFE ID (copy of the JWT "sub" claim).
id: SpiffeId,
/// Audience list — JWT "aud" claim.
audiences: List<Text>,
/// Compact-serialised JWT ("header.payload.signature").
token: Text,
/// Parsed "exp" claim.
expires_at: Instant,
/// Parsed "iat" claim, if present.
issued_at: Maybe<Instant>,
/// Remaining claims as opaque JSON passthrough.
extra_claims: Text,
};

impl JwtSvid {
pub fn id(&self) -> &SpiffeId;
pub fn token(&self) -> &Text;
pub fn audiences(&self) -> &List<Text>;
pub fn expires_at(&self) -> Instant;
pub fn issued_at(&self) -> &Maybe<Instant>;
pub fn is_expired(&self, now: Instant) -> Bool;
}

public type JwtBundle is {
trust_domain: Text,
/// JWKS-format JSON of trusted signing keys (RFC 7517).
jwks: Text,
};

impl JwtBundle {
pub fn new(trust_domain: Text, jwks: Text) -> JwtBundle;
pub fn trust_domain(&self) -> &Text;
pub fn jwks(&self) -> &Text;
}

Use case — HTTP Authorization: Bearer ... for service-to-service auth where mTLS is inconvenient (a proxy strips client certs, a browser client, etc.). The JWT carries the SPIFFE ID in its sub claim.

Bundle sets

X509BundleSet and JwtBundleSet hold a bundle per trust domain — workloads typically need to verify peers from their own trust domain AND any federated domains they interoperate with.


Workload API client — core.security.spiffe.workload_api

The SPIRE workload API runs as a local agent, listening on a Unix domain socket (default unix:/tmp/spire-agent/public/api.sock or unix:/run/spire/sockets/agent.sock). Processes attach and receive SVIDs + bundles, automatically rotated.

Socket discovery

mount core.security.spiffe.workload_api.{
endpoint_socket, WorkloadApiClient, WorkloadApiError,
X509SvidStream, JwtBundlesStream,
};

public fn endpoint_socket() -> Result<Text, WorkloadApiError>;

Resolves the socket path per the SPIFFE spec:

  1. SPIFFE_ENDPOINT_SOCKET env var (highest priority).
  2. Default OS path (/tmp/spire-agent/public/api.sock).
  3. Error if neither.

Client

mount core.async.cancellation.{CancellationToken};

public type WorkloadApiClient is {
socket_path: Text,
handle: UInt64,
};

impl WorkloadApiClient {
/// Connect using the env-var / default socket path.
pub async fn connect() -> Result<WorkloadApiClient, WorkloadApiError>;

/// Connect to an explicit UDS path (tests / non-standard deploys).
pub async fn connect_to(socket_path: &Text)
-> Result<WorkloadApiClient, WorkloadApiError>;

pub fn socket_path(&self) -> &Text;

/// One-shot X.509-SVID fetch. For rotation-aware apps use
/// `x509_svid_stream` which yields updates on each rotation.
pub async fn fetch_x509_svid(&self)
-> Result<X509SvidResponse, WorkloadApiError>;

/// Streaming X.509-SVID updates. Yields on every rotation and
/// trust-bundle change. Honour `token` for cancellation.
pub fn x509_svid_stream(&self, token: &CancellationToken) -> X509SvidStream;

/// Fetch a signed JWT-SVID for the given audiences. `subject`
/// is optional — when omitted, the agent picks the default
/// identity for this workload.
pub async fn fetch_jwt_svid(
&self,
audiences: &[Text],
subject: Maybe<&SpiffeId>,
) -> Result<JwtSvidResponse, WorkloadApiError>;

/// Stream JWT trust-bundle updates.
pub fn jwt_bundles_stream(&self, token: &CancellationToken) -> JwtBundlesStream;

/// Validate a JWT-SVID against the given audience using the
/// agent's own bundle — convenient when you don't want to manage
/// bundles yourself.
pub async fn validate_jwt_svid(
&self,
audience: &Text,
token: &Text,
) -> Result<JwtSvid, WorkloadApiError>;

pub async fn close(&self) -> Result<(), WorkloadApiError>;
}

X509SvidStream and JwtBundlesStream both implement Stream and AsyncIterator — use either .poll_next(cx) or for await item in stream { ... }.

Errors

public type WorkloadApiError is
| NoEndpointConfigured // SPIFFE_ENDPOINT_SOCKET unset/empty + no default
| ConnectFailed(UnixError) // socket connect failed
| HandshakeFailed(Text) // gRPC handshake / TLS error
| RpcError { code: Int, message: Text }
| ResponseMalformed(Text)
| SvidParseError(SpiffeIdError) // bad SPIFFE ID in a returned SVID
| NoIdentityAvailable // agent has no SVID for this workload
| Cancelled // cancellation token fired
| Closed; // client already closed

Quick example — fetch an SVID once

use core.security.spiffe.workload_api.{WorkloadApiClient};

async fn get_my_identity() -> Result<(), Error> {
// connect() automatically resolves the SPIFFE_ENDPOINT_SOCKET
// env var or falls back to the default path.
let client = WorkloadApiClient.connect().await?;
let resp = client.fetch_x509_svid().await?;

let me = &resp.svids[0];
println!("I am: {}", me.id().to_uri());
println!("My cert expires at: {}", me.expires_at());
Ok(())
}

Quick example — rotate automatically

use core.async.task;
use core.async.cancellation.{CancellationToken};

async fn run_with_rotation(mut app_tls_config: TlsConfig) {
let token = CancellationToken.new();
let client = WorkloadApiClient.connect().await.unwrap();

// The stream yields fresh SVIDs each time SPIRE rotates them.
let mut stream = client.x509_svid_stream(&token);

// Background task that reloads TLS config whenever a new SVID
// arrives. Cancel `token` to tear the stream down cleanly.
task.spawn(async move {
for await update in stream {
match update {
Ok(resp) => reload_tls(&mut app_tls_config, &resp.svids[0]),
Err(e) => log_error(&f"SVID stream error: {e}"),
}
}
});

// Your application runs...
}

This is the pattern behind net/weft/spiffe.vr's SpiffeAuthLayer and SpiffeClientTransport — they manage the SVID stream in the background and hand fresh credentials to the TLS stack.


Deployment patterns

mTLS with SPIFFE (service-to-service)

┌───────────┐ ┌────────────┐ ┌───────────┐
│ Service A │ │ SPIRE │ │ Service B │
│ │ │ agent │ │ │
└─────┬─────┘ └─────┬──────┘ └─────┬─────┘
│ attach() │ │ attach()
▼ ▼ ▼
X509Svid (rotates X509Svid
(bundle) every 30min) (bundle)

A speaks TLS to B:
handshake: A presents its X509Svid (client cert)
B verifies with its bundle
A verifies B's cert with its bundle
authorise: A checks the SPIFFE ID in B's cert SAN
against its allow-list; B reciprocates

JWT-SVID bearer-token

┌───────────┐ ┌────────────┐ ┌──────────┐
│ Service A │ │ SPIRE │ │ API GW │
└─────┬─────┘ └─────┬──────┘ └────┬─────┘
│ fetch_jwt_svid │ │
│ (audience=gw) ▼ │
│◀──── jwt ────┐ │
│ ▼ │
│ jwt:{sub:"spiffe://prod/A", │
│ aud:["gw"], │
│ exp:Instant+1h} │
│ │
│ GET /api Authorization: Bearer <jwt>
│─────────────────────────────────────▶
│ │ verify signature with bundle
│ │ check sub match ACL
│ │ check aud contains "gw"
│ │ proceed

Security considerations

Trust-domain discipline

Workloads within one trust domain implicitly trust each other's SPIRE-issued SVIDs. Federating across trust domains requires exchanging bundles — a trust-domain administrator publishes their bundle; peers import it to trust their SVIDs.

Do not mix trust domains casually. Creating spiffe://staging/ and spiffe://prod/ with a shared bundle is equivalent to granting staging workloads production identity.

SVID rotation

A workload that doesn't pick up bundle rotation will outage when its SVID expires. Always subscribe to the stream — don't just fetch_* once and cache forever.

Private-key protection

X509Svid.private_key is sensitive. Never:

  • Log it.
  • Transmit it outside the workload.
  • Write to disk without encryption.

SPIRE's delivery over the local Unix-domain socket is designed to keep the key in process memory; it never touches disk.

Wipe memory on drop using zeroise (planned P1).

Clock skew

SVID expiration is absolute time. A workload with a severely skewed clock will either over-use an expired cert (attacker wins) or reject valid SVIDs (denial of service on itself). Run NTP. Refuse to start with a clock > 5 min off.

What this module doesn't do

  • It doesn't run SPIRE. You need a SPIRE deployment — server (issuing SVIDs), agent (the local socket this module talks to), and attestors (the mechanism by which the agent verifies what process is attaching).
  • It doesn't manage trust-bundle distribution. Bundles come from the SPIRE agent (same workload-API), with bundle-federation handled at the server level.
  • It's not a replacement for standard TLS cert validation in higher-layer code — use it as the source of material for TlsConfig, not a separate verifier.

File layout

FileRole
core/security/spiffe/id.vrSpiffeId type — parse + validate — 167 LOC
core/security/spiffe/svid.vrX509Svid, JwtSvid, bundles, responses
core/security/spiffe/workload_api.vrSPIRE Workload API client
core/security/spiffe/mod.vrPublic re-exports
  • net.weft.spiffe — HTTP middleware that wraps these types for per-request SPIFFE auth.
  • core.net.tls — consumes X509Svid as identity cert + X509BundleSet as trust roots.
  • core.security.secrets — if you need a secret in addition to an identity (e.g. a DB password), fetch it from a secret store.

References