trstctl /docs Demo ↗ GitHub ↗

Workload identity — give software a verifiable identity, no secrets to steal

What it is

A workload is a running piece of software — a service, a container, a CI job, an AI agent — that proves what it is to other services without a long-lived password or API key planted inside it, by combining attestation (cryptographic proof of what and where a workload is) with short-lived credentials issued only to workloads that pass it.

The mental model: instead of a permanent badge every employee might lose, you install a fingerprint scanner at each door. The workload carries no secret — it proves what it is at the moment it needs access and gets a pass that expires in minutes. This page covers the SPIFFE standard, trstctl's attestation chain, ephemeral issuance, the non-human identity lifecycle, and a purpose-built AI-agent broker.

Why it exists

The classic way to give a service access — bake an API key or certificate into it — is also the classic way to get breached: those secrets get copied into logs, images, git history, and laptops, and rarely expire. Attestation-based, short-lived identity removes the thing attackers steal: nothing long-lived to leak, and a captured credential is useless within minutes. This is the foundation of "zero-trust" service-to-service security, and it matters even more for AI agents, which spin up fast, act with real privileges, and need tight, revocable scopes.

How it works

The attestation chain (F30) — proof before trust

Everything here rests on attestation: before issuing anything, trstctl demands and verifies proof of the workload's identity. The framework is pluggable — an Attestor verifies one kind of proof — and trstctl ships six:

  • TPM 2.0 quote — verifies a hardware TPM's endorsement chain back to the manufacturer root, plus a signed quote bound to a fresh nonce.
  • AWS IMDSv2 — verifies the PKCS#7-signed EC2 instance identity document against the AWS root.
  • GCP metadata — verifies the Google-signed instance-identity JWT against Google's JWKS.
  • Azure metadata — verifies the PKCS#7-signed IMDS attested document against a trusted Azure root.
  • Kubernetes projected SAT — verifies a pod's projected service-account token against the cluster's JWKS.
  • GitHub OIDC + Fulcio — verifies a GitHub Actions OIDC token and can produce a Sigstore/Fulcio binding for keyless code signing.

The verifier dispatches by method, computes a stable attestation ID through the single crypto path, adds an attestation node to the credential graph, and emits an immutable attestation.verified event — or, on failure, attestation.rejected and nothing else (fail-closed). Every attester must pass a conformance harness proving it accepts a genuine proof and rejects a forgery.

Served at /api/v1/workloads/attester-trust-sources and POST /api/v1/workloads/attested-issuance: workload owners with certs:issue manage (create/replace/rotate/revoke/delete) tenant trust sources for tpm, aws_iid, gcp_iit, azure_imds, k8s_sat, and github_oidc, and the binary builds the verifier from those records plus any configured process defaults. It verifies the proof, signs an X.509-SVID through the isolated signer, records the certificate as certificate.recorded, and binds the attestation with attestation.bound — or fails closed if no enabled trust source matches the method.

The SPIFFE Workload API (F24) — the standard interface

SPIFFE is the open standard for workload identity; its document is the SVID, delivered as an X.509 certificate or a JWT. trstctl implements a SPIRE-compatible Workload API server: a workload presents selectors (e.g. k8s:ns:default, k8s:sa:web), the server matches them against registration entries by set-subset (every required selector must be present), and issues the SVID. Signing goes through the single crypto path to keys in the separate, isolated signing service — private-key operations never run in the API process. A NeedsRotation helper flags an SVID for renewal once half-expired (SPIRE's policy); issuance runs in its own bounded lane, each step recorded as an immutable event.

Served as a gRPC service on a Unix domain socket (protocols.spiffe.enabled, default off): a spiffe-helper/go-spiffe/Envoy-SDS workload dials the socket and can call FetchX509SVID, FetchX509Bundles, FetchJWTSVID, FetchJWTBundles, and ValidateJWTSVID. X.509-SVIDs are signed through the isolated signing service; JWT-SVIDs use the signer-backed JWT handle and validate against the served JWT bundle. The Workload-API gRPC/protobuf contract is vendored verbatim from go-spiffe, so the wire format is byte-identical.

SPIRE upstream authority — keep SPIRE, anchor it in trstctl

If you already run SPIRE, trstctl can sit above it as the upstream private CA: the trstctl-spire-upstream-authority plugin implements SPIRE's UpstreamAuthority interface, so SPIRE keeps its local CA private key, sends only a CSR to trstctl, and gets a signed intermediate CA chain back. SPIRE keeps minting locally while trstctl becomes the governed root of trust — tenant-scoped API auth, idempotency, audit, and signer-backed CA custody.

The plugin calls the served route POST /api/v1/ca/authorities/{id}/intermediates/csr with csr_pem and a CA profile (common_name, ttl_seconds, max_path_len, optional DNS constraints), reading its token from a mounted file rather than a command-line argument. A stable Idempotency-Key on the CSR stops SPIRE retries from minting duplicate intermediates; the response is the SPIRE intermediate plus the trstctl upstream root.

UpstreamAuthority "trstctl" {
  plugin_cmd = "/opt/spire/plugins/trstctl-spire-upstream-authority"
  plugin_data {
    endpoint = "https://trstctl.example.com:8443"
    ca_authority_id = "11111111-1111-1111-1111-111111111111"
    token_file = "/run/secrets/trstctl-spire-token"
    common_name = "SPIRE Server CA"
    ttl_seconds = 3600
    max_path_len = 0
  }
}

This is container-proven end to end: CI runs a real SPIRE server, loads the plugin, mints an X.509-SVID, and verifies the chain as workload leaf -> SPIRE intermediate -> trstctl root. SPIRE's optional JWT upstream method isn't claimed by this plugin — X.509-SVID trust anchoring only.

Ephemeral issuance (F25) — attestation in, short-lived cert out

The ephemeral issuer ties it together: it verifies an attestation (refusing to sign on failure), mints a short-lived certificate (default TTL 15 minutes, clamped to a per-method maximum), and binds the attestation to the credential in the graph and audit trail. Every request takes an Idempotency-Key, so a retry never mints a second credential — it returns the original.

The direct X.509-SVID flavor is served when attested issuance is configured, at POST /api/v1/workloads/attested-issuance; the approval-gated JIT flavor is served when ephemeral issuance is configured, at POST /api/v1/ephemeral. The first call verifies the proof, opens a dual-control approval, and enqueues the notification intent in the same tenant transaction; after a distinct approver calls POST /api/v1/ephemeral/{request_id}/approvals, a fresh Idempotency-Key on POST /api/v1/ephemeral mints the short-TTL credential. The response carries certificate_pem, credential_id, certificate_id, subject, not_after, approval counts, and verified attestation metadata.

Non-human identity lifecycle (F59)

Beyond a single credential, the identity itself has a lifecycle: requested, issued, deployed, renewing, revoked, retired (terminal). trstctl models this as a guarded state machine — every transition goes through one served path enforcing the legal moves, updating PostgreSQL-backed identity rows and the credential graph projection, and emitting immutable lifecycle events (identity.created, identity.issued, identity.deployed, identity.revoked, identity.renewed, identity.retired).

The served REST routes POST /api/v1/identities and POST /api/v1/identities/{id}/transitions (both take an Idempotency-Key, so a retry never double-creates or double-applies) are the canonical identity lifecycle surface: there's no parallel in-memory NHI manager — the PostgreSQL-backed identity rows, orchestrator events, audit trail, graph projection, and OpenAPI/CLI paths are the product path operators run.

The AI-agent identity broker (F61)

AI agents are a sharp case: they appear fast, act with real privileges, and chain tools together, so an over-scoped or un-revocable credential is dangerous. The AI-agent identity broker is a dedicated issuance surface that (1) evaluates a policy decision before issuing — a deny records agent.identity.refused and signs nothing; (2) issues an attested, short-lived credential via the ephemeral issuer; (3) records the agent and its credential in the graph so you can ask blast radius ("everything this agent can reach") before trusting it. A tenant-wide broker history and one-call revocation console remain a roadmap residual, not part of the served GA claim.

Served when the agent broker is configured, at POST /api/v1/broker/agent-identities: the operator supplies the trust domain, attestors, Rego policy module, and signer-backed issuing CA. A request carries the agent id, attestation method, proof payload, public key, requested scopes, and optional TTL; trstctl verifies the proof, evaluates policy before signing, mints a short-lived X.509-SVID through the isolated signer, records certificate.recorded, and projects the agent-to-credential edge into the graph. Denies emit agent.identity.refused and return no credential.

In the console

The console adds a governance lens over non-human identities: a unified NHI inventory by kind (GET /api/v1/nhi/inventory), a risk-posture summary, orphan-governance for credentials whose custodian is gone or inactive, and a blast-radius explorer at /graph. The identity grid at /identities carries issue / deploy / revoke actions behind the same confirm and dual-control guards as the API. See The web console.

Use it

Create and transition a managed identity:

trstctl-cli identities create -f service-account.json
trstctl-cli identities transition <id> \
  -f '{"to":"revoked","reason":"cessationOfOperation"}'

Both map to POST /api/v1/identities and POST /api/v1/identities/{id}/transitions (mutations require an Idempotency-Key). Automated decommissioning from owner departure, vendor termination, or inactivity signals is governance, not an identity-lifecycle primitive: POST /api/v1/nhi/decommission (trstctl-cli nhi decommission) resolves those signals against managed NHIs and revokes or retires them via these same transitions. Canonical home: Policy & governance.

Deploying against an existing SPIRE cluster is just installing the plugin binary (or mounting it read-only) and adding the UpstreamAuthority "trstctl" block above with a token_file scoped to certs:issue on the owning tenant.

Attested X.509-SVID issuance needs an enabled trust source first (POST /api/v1/workloads/attester-trust-sources; name, method, issuer, audience, JWKS), then:

curl -sS -X POST https://localhost:8443/api/v1/workloads/attested-issuance \
  -H "Authorization: Bearer $TRSTCTL_TOKEN" \
  -H "Idempotency-Key: k8s-web-1" -H "Content-Type: application/json" \
  -d '{"method":"k8s_sat","payload_base64":"...","public_key_pem":"...",
      "ttl_seconds":600}'

The response is the certificate the workload should load, plus the verified subject that became the SPIFFE path (e.g. spiffe://example.org/ns/default/sa/web). Trust material rotates, revokes, and offboards via .../rotate, .../revoke, and DELETE .../{id}, each idempotent and recorded as an immutable event.

Approval-gated ephemeral/JIT issuance needs EphemeralIssuanceConfig (attestors, trust domain, signer-backed issuing CA, approval TTL, approval threshold). The requester opens the approval, a distinct approver records it (never themselves), then the requester mints with a fresh idempotency key:

trstctl-cli --idempotency-key jit-1-request ephemeral issue -f jit-request.json
trstctl-cli --idempotency-key jit-1-approve ephemeral approve jit-1 -f approval.json
trstctl-cli --idempotency-key jit-1-issue ephemeral issue -f jit-request.json

The first call returns state: "awaiting_approval" and no certificate; the approved call returns state: "issued" with a certificate whose not_after is clamped by the TTL policy. Replaying either key returns the same response without opening another approval or minting again.

The AI-agent broker works the same way once configured:

curl -sS -X POST https://localhost:8443/api/v1/broker/agent-identities \
  -H "Authorization: Bearer $TRSTCTL_TOKEN" \
  -H "Idempotency-Key: agent-7-issue" -H "Content-Type: application/json" \
  -d '{"agent_id":"agent-7","method":"k8s_sat","payload_base64":"...",
      "public_key_pem":"...","scopes":["mcp:graph.read","tool:inventory.read"],
      "ttl_seconds":600}'

The response includes the issued certificate, credential_id, certificate_id, verified attestation metadata, expiry, and the graph node_id for the agent workload. Replaying the same key returns the same response without minting twice.

Pitfalls & limits

Capability Status today
NHI lifecycle routes (F59) Served — /api/v1/identities, /transitions
SPIFFE Workload API (F24) Served — gRPC over a UDS (protocols.spiffe.enabled); FetchX509SVID, FetchJWTSVID, bundle fetches, and ValidateJWTSVID wired to the signer-backed path
SPIRE upstream authority Served and container-proven for X.509 — SPIRE loads trstctl-spire-upstream-authority, trstctl signs its intermediate CA CSR via /api/v1/ca/authorities/{id}/intermediates/csr, and the e2e verifies a minted SVID chain to the trstctl root
Ephemeral issuance (F25) Served — direct attested X.509-SVID mint at POST /api/v1/workloads/attested-issuance once a tenant trust source is enabled; approval-gated JIT mint at POST /api/v1/ephemeral plus /api/v1/ephemeral/{request_id}/approvals
Attestation chain (F30) Served — tenant trust-source lifecycle at /api/v1/workloads/attester-trust-sources; the six-attester verifier gates POST /api/v1/workloads/attested-issuance; conformance covers each attester
AI-agent broker (F61) Served when configured — POST /api/v1/broker/agent-identities verifies proof, gates policy, mints a short-lived credential, and projects the graph grant

Operationally: each attestation method needs public trust material configured first (cloud roots, cluster JWKS, TPM manufacturer roots), and short TTLs mean frequent renewal for workloads and agents — the point, but plan for it.

Reference

  • Served routes: POST /api/v1/identities, POST /api/v1/identities/{id}/transitions, GET /api/v1/workloads/attester-trust-sources, POST /api/v1/workloads/attester-trust-sources, PUT /api/v1/workloads/attester-trust-sources/{id}, POST /api/v1/workloads/attester-trust-sources/{id}/rotate, POST /api/v1/workloads/attester-trust-sources/{id}/revoke, DELETE /api/v1/workloads/attester-trust-sources/{id}, POST /api/v1/workloads/attested-issuance, POST /api/v1/ephemeral, POST /api/v1/ephemeral/{request_id}/approvals, POST /api/v1/broker/agent-identities, POST /api/v1/ca/authorities/{id}/intermediates/csr.
  • Attestation methods: tpm, aws_iid, gcp_iit, azure_imds, k8s_sat, github_oidc.
  • SPIFFE: FetchX509SVID, FetchX509Bundles, FetchJWTSVID, FetchJWTBundles, ValidateJWTSVID; selector match is set-subset.
  • Events: attestation.verified/rejected/bound, ephemeral.approval.requested, ephemeral.approval.granted, ephemeral.issued, spiffe.svid.issued, certificate.recorded, identity.created, identity.{issued,deployed,revoked,renewed,retired}, agent.identity.{issued,refused,revoked}.

See also

SSH (attestation-gated SSH certs use the same chain) · Issuance & certificate authorities · Graph, query & AI (blast radius) · Policy & governance (the broker's policy gate and NHI decommissioning) · glossary: workload, attestation, SPIFFE/SVID

Covers: F24, F25, F30, F59, F61

Rendered live from github.com/ctlplne/trstctl — found a mistake? edit this page.