PromptisePromptise
Docs
GitHub
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Guides> Guardrails and security

SPIFFE for AI Agents: Workload Identity Without API Keys

Use SPIFFE and SPIRE for AI agent identity: X.509 vs JWT-SVIDs, calling APIs and MCP servers, cloud workload identity compared, with runnable code.

Level
Advanced
Reading time
20 min
Published
Oct 20, 2026
Last updated
Oct 20, 2026
Tested with
Promptise 1.4.3
By
Promptise Team
  • Authentication and Access Control
  • Model Context Protocol (MCP)
  • Deployment
  • Audit Logging and Monitoring
Contents · 18 sections↓
  1. 01How do you use SPIFFE for AI agents?
  2. 02What are SPIFFE and SPIRE?
  3. 03Why do AI agents need workload identity instead of shared API keys?
  4. 04Should an agent use an X.509-SVID or a JWT-SVID?
  5. 05How does SPIFFE compare with cloud workload identity?
  6. 06When to use SPIFFE for agents (and when not)
  7. 07What you need
  8. 08Step 1: Start a stand-in Workload API
  9. 09Step 2: Fetch and validate a JWT-SVID
  10. 10Step 3: Give a Promptise agent its SPIFFE identity
  11. 11Step 4: Verify SVIDs on an MCP server
  12. 12Step 5: Run the agent against the server
  13. 13The complete example
  14. 14Running it in production with SPIRE
  15. 15Common problems and fixes
  16. 16Limitations
  17. 17Frequently asked questions
  18. 18Next steps

SPIFFE gives AI agents a cryptographic workload identity instead of a shared API key. This guide is for platform and backend engineers who run agents in Kubernetes or on VMs and need every API and MCP server an agent calls to know which agent is calling. It covers SPIFFE and SPIRE in brief, X.509 versus JWT-SVIDs, using an SVID with APIs and MCP servers, how SPIFFE compares with cloud workload identity, and runnable Python. It also says plainly which parts need a real SPIRE deployment.

[01]

How do you use SPIFFE for AI agents?

Register each agent as a workload in SPIRE with its own SPIFFE ID, such as spiffe://example.org/agent/billing-bot. At runtime the agent asks the SPIRE agent on its node for an SVID through the Workload API, so no secret is stored in the agent's code, image or environment. To call an HTTP API or an MCP server, the agent requests a JWT-SVID for that service's audience and sends it as a bearer token. The server verifies the signature against the trust domain's keys, checks the audience and expiry, and authorizes the SPIFFE ID. For service-to-service mutual TLS, use the X.509-SVID instead.

  • One agent, one SPIFFE ID. Every call can be attributed and scoped to that agent, which a shared key can't do.

  • SPIRE proves the identity by attestation. It checks the node and the process (Kubernetes service account, Unix user, container image) before it issues anything.

  • SVIDs are short-lived and rotate automatically. SPIRE's defaults are one hour for X.509-SVIDs and five minutes for JWT-SVIDs.

  • Use one audience per JWT-SVID. A token minted for api://billing is rejected by a server that expects api://crm.

  • SPIFFE answers "who", not "what is allowed". Authorization stays on the server.


[02]

What are SPIFFE and SPIRE?

SPIFFE (Secure Production Identity Framework For Everyone) is a set of open standards for identifying software in dynamic environments: a machine identity for workloads, where people have user accounts. SPIRE is its production implementation, and many tools ship SPIFFE support, so a SPIFFE ID can serve as an agent's machine ID across them. The vocabulary is small:

  • SPIFFE ID: a URI of the form spiffe://<trust-domain>/<path>, such as spiffe://example.org/agent/billing-bot. The trust domain is your organization's identity namespace.

  • SVID (SPIFFE Verifiable Identity Document): the credential that carries a SPIFFE ID. An X.509-SVID is a certificate with the SPIFFE ID as its only URI SAN. A JWT-SVID is a signed JWT whose sub claim is the SPIFFE ID, with a required aud and exp.

  • Trust bundle: the public keys and CA certificates of a trust domain, which a verifier uses to check SVIDs.

  • Workload API: a local gRPC API, usually on a Unix socket named by SPIFFE_ENDPOINT_SOCKET, that hands a workload its SVIDs and the trust bundles. The workload sends no credentials to it. The API works out who is calling.

  • Federation: trust domains exchange bundles so workloads in one can verify SVIDs from another.

SPIRE has two parts. The SPIRE server is the signing authority for a trust domain: it stores registration entries and keys and signs SVIDs. A SPIRE agent runs on every node, attests the node to the server once, serves the Workload API locally, and attests each workload that calls it. A registration entry maps a SPIFFE ID to selectors, the properties a process must have to receive it, such as k8s:ns:payments and k8s:sa:billing-bot.

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
SPIRE server  (signing authority for spiffe://example.org)
   │  registration entry: spiffe://example.org/agent/billing-bot
   │                      ← selectors k8s:ns:payments, k8s:sa:billing-bot
   ▼
SPIRE agent on the node ──── node attestation (once)
   │  Workload API on a Unix socket
   │  workload attestation: which pod, which service account?
   ▼
billing agent ── FetchJWTSVID(audience=api://billing) ──▶ JWT-SVID (5 min)
   │
   │  Authorization: Bearer <JWT-SVID>
   ▼
billing MCP server ── verify signature (trust bundle), aud, exp
                   ── authorize sub = spiffe://example.org/agent/billing-bot

[03]

Why do AI agents need workload identity instead of shared API keys?

A shared API key says "someone who has this key". For an agent that acts on its own, that is not enough:

  • Attribution. With one key across a fleet, the server can't tell the billing agent from the reporting agent. With SPIFFE every request names its agent.

  • Least privilege. Each agent gets only the tools its job needs. A server can allow refunds for one SPIFFE ID and read-only access for another.

  • Nothing to steal at rest. A key in an environment variable is one prompt injection or one leaked log away from an attacker. An SVID is fetched from a local socket and expires within minutes.

  • Rotation and revocation without redeploys. SPIRE rotates SVIDs on its own. Deleting a registration entry stops new SVIDs, and the existing ones expire soon after.

  • Audit. "Which agent issued this refund?" has an answer you can verify after the fact.

One limit matters for agents in particular: a SPIFFE ID identifies the workload, not the user it acts for. If the agent works on behalf of a person, carry that person's identity separately, for example with OAuth token exchange. See AI agent identity and authentication for that split.


[04]

Should an agent use an X.509-SVID or a JWT-SVID?

X.509-SVID

JWT-SVID

Presented as

Client certificate in a mutual TLS handshake

Authorization: Bearer header

Proof

The caller proves it holds the private key

Bearer: anyone holding the token can use it

Scope

Any service that trusts the bundle

One audience per token, by recommendation

SPIRE default lifetime

1 hour

5 minutes

Through proxies and gateways

Ends where TLS ends

Survives TLS termination

Best for

Service-to-service traffic, service meshes

HTTP APIs, MCP servers behind load balancers

The JWT-SVID specification warns that JWT-SVIDs, as bearer tokens, are open to replay, and recommends short expiry and a single audience. X.509-SVIDs don't have that problem, because the private key never leaves the workload. In practice, agents use both: mutual TLS (often through a service mesh) for transport between services you run, and JWT-SVIDs when the request crosses a gateway or reaches an MCP server that authenticates bearer tokens. The IETF WIMSE working group is defining workload tokens that are cryptographically bound to the caller, which would close the replay gap for the JWT path.


[05]

How does SPIFFE compare with cloud workload identity?

Every major cloud has its own workload identity, and for an agent that only runs in one cloud and only calls that cloud's services, it is usually the simpler choice.

SPIFFE / SPIRE

AWS IAM

Microsoft Entra

Google Cloud

Identity

SPIFFE ID per workload

IAM role

Workload identity (service principal, managed identity), Entra Agent ID for agents

Service account

Where it works

Any cloud, on-premises, Kubernetes, VMs

AWS

Azure and Entra-protected apps

Google Cloud

Credential

X.509-SVID or JWT-SVID

Temporary AWS credentials, plus JWTs for external services

Entra access tokens

OAuth access tokens and ID tokens

Getting one

Workload API on the node

Instance roles, IRSA, EKS Pod Identity, STS GetWebIdentityToken

IMDS, workload identity federation

Metadata server, workload identity federation

Bridges

Its OIDC Discovery Provider lets cloud IAM trust JWT-SVIDs

IAM Roles Anywhere accepts X.509 from your CA

Federated credentials trust external OIDC issuers

Workload identity federation accepts OIDC tokens and X.509

In Promptise

from_spiffe(token_file=...), or CallableTokenProvider

AgentIdentity.from_aws()

AgentIdentity.from_entra()

AgentIdentity.from_gcp()

Three cloud features are new enough to know about. AWS IAM outbound identity federation (GetWebIdentityToken) lets AWS workloads get short-lived JWTs for services outside AWS, once you enable it for the account. Microsoft Entra Agent ID gives agents their own identity type, built from reusable blueprints, with a sponsor accountable for each agent and an agent registry. Google Cloud workload identity federation accepts X.509 certificates as well as OIDC tokens. The rows above come from each provider's documentation. SPIFFE's advantage is the same identity in every environment. The clouds' advantage is that their own services understand their own identities with no bridge.

Where does Promptise fit? Promptise is not an identity provider and does not replace SPIRE or a cloud's IAM. It consumes them: AgentIdentity takes a credential from any of the sources in the last row (or AgentIdentity.auto() picks one from the environment), stamps the identity on the agent's traces, and presents a per-audience token to each MCP server. On the server side, JwksAuth verifies tokens from any of them the same way. So you can start with a cloud identity and move to SPIFFE, or run both, without changing agent or server code beyond the factory call.


[06]

When to use SPIFFE for agents (and when not)

Use SPIFFE when:

  • your agents run across clusters, clouds or on-premises sites and need one identity scheme;

  • you already run SPIRE or a SPIFFE-based mesh (Istio, Consul and others issue SPIFFE IDs);

  • several agents share infrastructure and must get different permissions;

  • agents call internal MCP servers and APIs that you control.

Use your cloud's workload identity instead when the agent lives in one cloud and mostly calls that cloud's services. Use a local identity with no credential when you only need to tell agents apart in logs. SPIRE is infrastructure to operate: a server with a datastore, an agent on every node, and registration entries to maintain.


[07]

What you need

  • Python 3.10 or later, pip install "promptise>=1.4.3" spiffe. spiffe is py-spiffe, the Python SPIFFE library, imported as spiffe.

  • An OpenAI API key in OPENAI_API_KEY for step 5, which uses gpt-5-mini.

  • About 25 minutes.

What runs without SPIRE: everything below. Step 1 starts a stand-in that speaks the real Workload API protocol over a Unix socket and serves the trust bundle over HTTP, so the client code is the code you would run against a SPIRE agent. What needs real SPIRE: node and workload attestation, registration entries, key management and rotation, federation, and the OIDC Discovery Provider. The stand-in hands every caller of a socket the same identity, so treat it as a development fixture only.


[08]

Step 1: Start a stand-in Workload API

The stand-in serves two sockets, one per workload, in place of attestation: a process that opens billing.sock is the billing agent. It signs JWT-SVIDs with an EC P-256 key and requires the Workload API's security header on every call, as SPIRE does.

Pythonstand_in_workload_api.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
"""A stand-in for a SPIRE agent, for local development only.

It speaks the real SPIFFE Workload API (gRPC over a Unix socket), so py-spiffe
and spiffe-helper talk to it as they would to a SPIRE agent. It does no
attestation: every process that can open a socket gets that socket's SPIFFE ID.
It also serves the trust domain's public keys over HTTP, like SPIRE's OIDC
Discovery Provider. Replace all of it with SPIRE in any real deployment.
"""

import json
import os
import threading
import time
import uuid
from concurrent import futures
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import grpc
import jwt
from cryptography.hazmat.primitives.asymmetric import ec
from spiffe._proto import workload_pb2, workload_pb2_grpc

TRUST_DOMAIN = "example.org"
SOCKET_DIR = os.environ.get("SOCKET_DIR", "/tmp/spire-agent")
JWT_TTL = 300  # seconds; SPIRE's default JWT-SVID TTL is 5 minutes

# One socket per workload stands in for workload attestation.
WORKLOADS = {
    "billing.sock": f"spiffe://{TRUST_DOMAIN}/agent/billing-bot",
    "reporting.sock": f"spiffe://{TRUST_DOMAIN}/agent/reporting-bot",
}

SIGNING_KEY = ec.generate_private_key(ec.SECP256R1())
KEY_ID = uuid.uuid4().hex[:12]
JWKS = {"keys": [{**json.loads(jwt.algorithms.ECAlgorithm.to_jwk(SIGNING_KEY.public_key())),
                  "kid": KEY_ID, "use": "sig", "alg": "ES256"}]}


class WorkloadAPI(workload_pb2_grpc.SpiffeWorkloadAPIServicer):
    def __init__(self, spiffe_id: str):
        self.spiffe_id = spiffe_id

    def _require_header(self, context):
        # The Workload API spec requires this header on every call.
        if ("workload.spiffe.io", "true") not in context.invocation_metadata():
            context.abort(grpc.StatusCode.INVALID_ARGUMENT, "security header missing")

    def FetchJWTSVID(self, request, context):
        self._require_header(context)
        now = int(time.time())
        claims = {"sub": self.spiffe_id, "aud": list(request.audience),
                  "iat": now, "exp": now + JWT_TTL}
        token = jwt.encode(claims, SIGNING_KEY, algorithm="ES256", headers={"kid": KEY_ID, "typ": "JWT"})
        print(f"[workload-api] JWT-SVID for {self.spiffe_id}, aud={list(request.audience)}", flush=True)
        return workload_pb2.JWTSVIDResponse(
            svids=[workload_pb2.JWTSVID(spiffe_id=self.spiffe_id, svid=token)])

    def FetchJWTBundles(self, request, context):
        self._require_header(context)
        yield workload_pb2.JWTBundlesResponse(bundles={TRUST_DOMAIN: json.dumps(JWKS).encode()})
        while context.is_active():
            time.sleep(1)


class Keys(BaseHTTPRequestHandler):
    def do_GET(self):
        body = json.dumps(JWKS).encode()
        self.send_response(200 if self.path == "/keys" else 404)
        self.send_header("content-type", "application/json")
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, *args):
        pass


if __name__ == "__main__":
    os.makedirs(SOCKET_DIR, exist_ok=True)
    servers = []
    for name, spiffe_id in WORKLOADS.items():
        path = os.path.join(SOCKET_DIR, name)
        if os.path.exists(path):
            os.remove(path)
        server = grpc.server(futures.ThreadPoolExecutor(max_workers=8))
        workload_pb2_grpc.add_SpiffeWorkloadAPIServicer_to_server(WorkloadAPI(spiffe_id), server)
        server.add_insecure_port(f"unix:{path}")
        server.start()
        servers.append(server)
        print(f"[workload-api] {name} -> {spiffe_id}", flush=True)
    http = ThreadingHTTPServer(("127.0.0.1", 8899), Keys)
    threading.Thread(target=http.serve_forever, daemon=True).start()
    print("[workload-api] trust bundle (JWKS) at http://127.0.0.1:8899/keys", flush=True)
    servers[0].wait_for_termination()

Run it in its own terminal with SOCKET_DIR=sock python stand_in_workload_api.py:

Output
[workload-api] billing.sock -> spiffe://example.org/agent/billing-bot
[workload-api] reporting.sock -> spiffe://example.org/agent/reporting-bot
[workload-api] trust bundle (JWKS) at http://127.0.0.1:8899/keys

With SPIRE, you skip this step. The SPIRE agent's socket is usually /tmp/spire-agent/public/api.sock (or mounted into the pod), and its OIDC Discovery Provider publishes the keys.


[09]

Step 2: Fetch and validate a JWT-SVID

This step is what every SPIFFE client does: open the Workload API, ask for a JWT-SVID for one audience, and, on the receiving side, validate it against the trust bundle.

Pythonfetch_svid.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
"""Fetch a JWT-SVID from the Workload API and validate it, with py-spiffe."""

import os
import time

from spiffe import JwtSvid, WorkloadApiClient

SOCKET = os.environ.get("SPIFFE_ENDPOINT_SOCKET", "unix:///tmp/spire-agent/public/api.sock")

with WorkloadApiClient(socket_path=SOCKET) as client:
    # Ask for an SVID scoped to the one service the agent is about to call.
    svid = client.fetch_jwt_svid(audience={"api://billing"})
    print("SPIFFE ID:  ", svid.spiffe_id)
    print("audience:   ", svid.audience)
    print("expires in: ", round(svid.expiry - time.time()), "seconds")
    print("token:      ", svid.token[:40] + "...")

    # What a receiving service does: check the signature against the trust
    # bundle, the audience and the expiry.
    bundles = client.fetch_jwt_bundles()
    bundle = bundles.get_bundle_for_trust_domain(svid.spiffe_id.trust_domain)
    checked = JwtSvid.parse_and_validate(svid.token, bundle, {"api://billing"})
    print("valid for api://billing:", checked.spiffe_id)
    try:
        JwtSvid.parse_and_validate(svid.token, bundle, {"api://crm"})
    except Exception as exc:  # the audience does not match
        print("valid for api://crm:    ", type(exc).__name__, "-", exc)
>_Terminal
SPIFFE_ENDPOINT_SOCKET=unix:sock/billing.sock python fetch_svid.py
Output
SPIFFE ID:   spiffe://example.org/agent/billing-bot
audience:    {'api://billing'}
expires in:  300 seconds
token:       eyJhbGciOiJFUzI1NiIsImtpZCI6IjdkZjg0YmY2...
valid for api://billing: spiffe://example.org/agent/billing-bot
valid for api://crm:     InvalidTokenError - Audience doesn't match

The agent never configured its own name: the SPIFFE ID came from the socket, as it would come from SPIRE's attestation. The token lives for 300 seconds, SPIRE's default. Validation passed for the audience it was minted for and failed for another, which is the property that stops one service from replaying a token at another. JwtSvid.parse_and_validate is what a Python service would call. For an MCP server, Promptise does the same check in step 4.


[10]

Step 3: Give a Promptise agent its SPIFFE identity

A Promptise AgentIdentity stamps an identifier on everything the agent does and, when backed by a credential provider, presents a signed credential to the servers it calls. CallableTokenProvider wraps any function that returns a JWT for an audience, here a call to the Workload API:

Pythonspiffe_identity.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
"""The agent's identity: a JWT-SVID from the local SPIRE agent, per audience."""

import os

from promptise.identity import AgentIdentity, CallableTokenProvider
from spiffe import WorkloadApiClient

SOCKET = os.environ.get("SPIFFE_ENDPOINT_SOCKET", "unix:///tmp/spire-agent/public/api.sock")


def fetch_jwt_svid(audience: str | None) -> str:
    """Ask the Workload API for a JWT-SVID scoped to one audience."""
    with WorkloadApiClient(socket_path=SOCKET) as client:
        return client.fetch_jwt_svid(audience={audience}).token


identity = AgentIdentity(
    "billing-bot",
    owner="payments",
    credential=CallableTokenProvider(
        token_fn=fetch_jwt_svid,
        provider_label="spiffe-workload-api",
        default_audience="api://billing",
    ),
)

if __name__ == "__main__":
    from promptise.identity import decode_jwt_claims

    print("verifiable:", identity.is_verifiable, "| provider:", identity.credential_provider)
    print("subject:   ", identity.subject())
    for audience in ("api://billing", "api://crm"):
        claims = decode_jwt_claims(identity.get_credential(audience))
        print(f"token for {audience}: sub={claims['sub']} aud={claims['aud']}")
Output
verifiable: True | provider: spiffe-workload-api
subject:    spiffe://example.org/agent/billing-bot
token for api://billing: sub=spiffe://example.org/agent/billing-bot aud=['api://billing']
token for api://crm: sub=spiffe://example.org/agent/billing-bot aud=['api://crm']

identity.subject() reads the SPIFFE ID from the SVID, so the identity in your logs is the one SPIRE vouches for. Asking for two audiences produced two tokens, each scoped to one server. The provider caches each token and fetches a new one 60 seconds before it expires, so a five-minute SVID is renewed without any code of yours.

Promptise also has a built-in factory, AgentIdentity.from_spiffe(). Its file mode reads a JWT-SVID that spiffe-helper keeps on disk, which needs no Python SPIFFE library at all:

Pythonfile_mode.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"""Promptise's built-in SPIFFE file mode: read the JWT-SVID that spiffe-helper keeps on disk."""

import os

from promptise.identity import AgentIdentity
from spiffe import WorkloadApiClient

TOKEN_FILE = "svids/jwt_svid.token"

# Stand-in for spiffe-helper, which fetches the JWT-SVID and rewrites this file before it expires.
os.makedirs("svids", exist_ok=True)
with WorkloadApiClient(socket_path=os.environ["SPIFFE_ENDPOINT_SOCKET"]) as client:
    with open(TOKEN_FILE, "w") as f:
        f.write(client.fetch_jwt_svid(audience={"api://billing"}).token)

identity = AgentIdentity.from_spiffe("billing-bot", token_file=TOKEN_FILE)
print("provider:", identity.credential_provider)
print("subject: ", identity.subject())
print("audience:", identity.idp_claims()["aud"])
Output
provider: spiffe-file
subject:  spiffe://example.org/agent/billing-bot
audience: ['api://billing']

File mode re-reads the file on every refresh, so spiffe-helper's rotation is picked up. The trade-off is one fixed audience per file. Its SDK mode (mode="sdk") talks to the Workload API directly, but in Promptise 1.4.3 it expects a package named pyspiffe, while the library on PyPI is spiffe. Until that changes, use file mode or the callable shown above.


[11]

Step 4: Verify SVIDs on an MCP server

The server side needs three things: the trust domain's public keys, the audience it accepts, and a rule for which SPIFFE IDs may call which tools. Promptise's JwksAuth checks the signature, audience and expiry, and RequireClientId authorizes a SPIFFE ID, because a JWT-SVID's sub is the SPIFFE ID.

Pythonbilling_server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
"""An MCP server that accepts only JWT-SVIDs from the trust domain, for its own audience."""

import os
import time

from promptise.mcp.server import AuthMiddleware, JwksAuth, MCPServer, RequestContext, RequireClientId

# SPIRE's OIDC Discovery Provider publishes the trust bundle as a JWKS.
JWKS_URL = os.environ.get("SPIFFE_JWKS_URL", "https://oidc.example.org/keys")

server = MCPServer("billing", require_auth=True)
server.add_middleware(AuthMiddleware(JwksAuth(jwks_url=JWKS_URL, audience="api://billing")))


@server.tool(read_only_hint=True)
async def whoami(ctx: RequestContext) -> dict:
    """Show which SPIFFE ID the billing server verified for this call."""
    return {"spiffe_id": ctx.client.client_id, "audience": ctx.client.audience,
            "expires_in_s": round(ctx.client.expires_at - time.time())}


@server.tool(guards=[RequireClientId("spiffe://example.org/agent/billing-bot")])
async def issue_refund(ctx: RequestContext, invoice_id: str, amount: float) -> str:
    """Refund part of an invoice. Only the billing agent may call this."""
    return f"Refunded {amount:.2f} on {invoice_id} for {ctx.client.client_id}"


if __name__ == "__main__":
    server.run(transport="http", host="127.0.0.1", port=8762)

Start it with SPIFFE_JWKS_URL=http://127.0.0.1:8899/keys python billing_server.py. Then call issue_refund directly with three SVIDs, no model involved:

Pythonimpostors.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
"""Call the billing tools with SVIDs that should not work."""

import asyncio
import json

from promptise.mcp.client import MCPClient
from spiffe import WorkloadApiClient


def svid(socket: str, audience: str) -> str:
    with WorkloadApiClient(socket_path=socket) as client:
        return client.fetch_jwt_svid(audience={audience}).token


async def attempt(label: str, token: str) -> None:
    try:
        async with MCPClient(url="http://127.0.0.1:8762/mcp", bearer_token=token) as client:
            result = await client.call_tool("issue_refund", {"invoice_id": "INV-1042", "amount": 25.0})
            text = result.content[0].text
            if text.lstrip().startswith("{"):  # a tool error comes back as JSON
                error = json.loads(text)["error"]
                text = f"{error['code']}: {error['message']}"
            print(f"{label}: {text}")
    except Exception as exc:
        print(f"{label}: {type(exc).__name__}: {exc}")


async def main():
    billing, reporting = "unix:sock/billing.sock", "unix:sock/reporting.sock"
    await attempt("billing-bot, aud=api://crm    ", svid(billing, "api://crm"))
    await attempt("reporting-bot, aud=api://billing", svid(reporting, "api://billing"))
    await attempt("billing-bot, aud=api://billing", svid(billing, "api://billing"))


asyncio.run(main())
Output
billing-bot, aud=api://crm    : MCPConnectionRejectedError: Server at http://127.0.0.1:8762/mcp rejected the connection: 401 Unauthorized. Check the bearer_token/api_key configured for it.
reporting-bot, aud=api://billing: ACCESS_DENIED: Client 'spiffe://example.org/agent/reporting-bot' is not allowed to call this tool
billing-bot, aud=api://billing: Refunded 25.00 on INV-1042 for spiffe://example.org/agent/billing-bot

Each layer did its job:

  • Wrong audience: rejected at the door. The billing agent's own SVID, minted for api://crm, got a 401 before any tool ran.

  • Right audience, wrong agent: refused by the guard. The reporting agent is authenticated (its SVID is valid for api://billing) but not authorized for refunds.

  • Right audience, right agent: allowed, and the tool knows which SPIFFE ID called it.

With SPIRE, SPIFFE_JWKS_URL points at the OIDC Discovery Provider, and you add issuer= once you set jwt_issuer on the SPIRE server.


[12]

Step 5: Run the agent against the server

Now the agent itself. build_agent(identity=...) presents the identity to every HTTP server that has no bearer_token of its own, minted for that server's audience. It asks for the token on every request and refreshes it once if a server answers 401.

Pythonagent.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
"""A billing agent that authenticates to its MCP server with its SPIFFE identity."""

import asyncio

from promptise import build_agent
from promptise.config import HTTPServerSpec

from spiffe_identity import identity


async def main():
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={
            # No bearer_token: the agent presents a JWT-SVID minted for this audience.
            "billing": HTTPServerSpec(url="http://127.0.0.1:8762/mcp", audience="api://billing"),
        },
        identity=identity,
        instructions="You are a billing assistant. Use your tools. Answer in two short sentences.",
    )
    try:
        result = await agent.ainvoke({"messages": [{"role": "user", "content":
            "Which identity does the billing server see for you? Then refund 25.00 on invoice INV-1042."}]})
        print(result["messages"][-1].content)
    finally:
        await agent.shutdown()


asyncio.run(main())
Output
The billing server sees the identity spiffe://example.org/agent/billing-bot. I refunded 25.00 on invoice INV-1042.

The model never handled a credential: the token went into the HTTP header below the tool layer, so a prompt injection can't ask the model to print it. The server saw the SPIFFE ID and authorized the refund. To move this to production, replace the stand-in with SPIRE and point the socket and the JWKS URL at it. The agent and server code stay the same.


[13]

The complete example

Text
1
2
3
4
5
6
7
8
spiffe-agent/
├── stand_in_workload_api.py  # development only: Workload API + JWKS (step 1)
├── fetch_svid.py             # fetch and validate a JWT-SVID with py-spiffe (step 2)
├── spiffe_identity.py        # AgentIdentity backed by the Workload API (step 3)
├── file_mode.py              # AgentIdentity.from_spiffe(token_file=...) (step 3)
├── billing_server.py         # MCP server: JwksAuth + RequireClientId (step 4)
├── impostors.py              # wrong audience, wrong agent, right agent (step 4)
└── agent.py                  # the billing agent (step 5)
>_Terminal
pip install "promptise>=1.4.3" spiffe
SOCKET_DIR=sock python stand_in_workload_api.py &
SPIFFE_JWKS_URL=http://127.0.0.1:8899/keys python billing_server.py &
export SPIFFE_ENDPOINT_SOCKET=unix:sock/billing.sock
python fetch_svid.py && python spiffe_identity.py && python impostors.py
OPENAI_API_KEY=sk-... python agent.py

[14]

Running it in production with SPIRE

Register one SPIFFE ID per agent

Give each agent type its own SPIFFE ID and bind it to selectors that only its pods have. In Kubernetes, that is usually the namespace and the service account (from the SPIRE docs; requires a running SPIRE server):

>_Terminal
spire-server entry create \
  -parentID spiffe://example.org/ns/spire/sa/spire-agent \
  -spiffeID spiffe://example.org/agent/billing-bot \
  -selector k8s:ns:payments \
  -selector k8s:sa:billing-bot \
  -jwtSVIDTTL 300

Give every agent its own service account. If two agents share one, SPIRE can't tell them apart either.

Deliver the SVID: socket or spiffe-helper

Mount the SPIRE agent's socket into the pod and set SPIFFE_ENDPOINT_SOCKET, then use the callable from step 3. If you would rather keep the SPIFFE library out of the agent's image, run spiffe-helper as a sidecar and use file mode. Its configuration, from the spiffe-helper README:

Text
agent_address = "/tmp/spire-agent/public/api.sock"
cert_dir = "/run/spiffe"
daemon_mode = true
jwt_svids = [{jwt_audience="api://billing", jwt_svid_file_name="jwt_svid.token"}]

Publish the keys with the OIDC Discovery Provider

SPIRE's OIDC Discovery Provider serves the trust domain's JWT signing keys as a standard JWKS. Point JwksAuth(jwks_url=...) at it, or use JwksAuth.from_discovery(issuer=..., audience=...) when you set jwt_issuer on the SPIRE server. JwksAuth fetches keys on demand and caches them, so SPIRE's key rotation needs no change on the server. The same provider lets AWS, Google Cloud and Entra trust your JWT-SVIDs through their OIDC federation, as SPIFFE's AWS tutorial shows.

Keep authorization on the server

A valid SVID proves who is calling, not what it may do. Use RequireClientId or roles per tool, keep dangerous tools behind human approval, and treat each MCP server as its own audience. The MCP server authentication guide covers JWT validation and scopes in depth.

Record the verified identity in the audit log

Promptise's AuditMiddleware writes one entry per tool call and includes the verified identity (subject, issuer, audience, roles) inside its tamper-evident HMAC chain. With SPIFFE, the subject is the SPIFFE ID. See AI agent audit logs.

Federate for multiple clusters

Agents in one trust domain that call servers in another need the other domain's bundle. SPIRE federation exchanges bundles, which contain only public keys, between trust domains. Plan trust domains early, because SPIFFE IDs include them.


[15]

Common problems and fixes

Symptom

Cause

Fix

pip install promptise[identity-spiffe] fails to resolve

The extra in 1.4.3 points at pyspiffe, which is not on PyPI

pip install spiffe and use the callable from step 3, or file mode

ProviderConfigError: ... requires pyspiffe

from_spiffe() without token_file, or AgentIdentity.auto() with SPIFFE_ENDPOINT_SOCKET set, chose SDK mode

Pass token_file= (spiffe-helper), or use CallableTokenProvider

The Workload API call fails or hangs

No SPIRE agent on the socket, or the socket isn't mounted into the pod

Check SPIFFE_ENDPOINT_SOCKET and the volume mount

The SPIRE agent returns no SVID

No registration entry matches the workload's selectors

Compare spire-server entry show with the pod's namespace and service account

The MCP server answers 401

The SVID's audience isn't the server's audience, or keys don't match

Set the same audience on HTTPServerSpec and JwksAuth, check the JWKS URL

ACCESS_DENIED from a tool

The SPIFFE ID isn't in the tool's guard

Add the ID to RequireClientId, or fix the registration entry

Every call fails after a few minutes

A token was copied once instead of fetched per request

Let the identity fetch tokens (build_agent(identity=...)), never hard-code an SVID

Failed to bind to address unix:... when starting the stand-in

gRPC allows at most 103 characters in a Unix socket path

Use a short or relative path, such as SOCKET_DIR=sock


[16]

Limitations

  • The stand-in is not SPIRE. It does no attestation, so it shows the protocol and the code, not the security. Everything that makes SPIFFE trustworthy happens in SPIRE.

  • JWT-SVIDs are bearer tokens. Keep their lifetime short and their audience single, and prefer mutual TLS where the path allows it.

  • SPIFFE identifies workloads, not users. For actions on behalf of a person, combine it with user delegation.

  • This guide doesn't show mutual TLS. X.509-SVIDs are usually handled by a service mesh or by py-spiffe's TLS helpers, outside the agent framework.

  • Promptise's built-in SDK mode needs a fix in 1.4.3, as described in step 3. File mode and CallableTokenProvider work today.


[17]

Frequently asked questions

How do you authenticate AI agents?

Give each agent its own identity and a short-lived credential from an identity system, not a shared API key. With SPIFFE, the agent fetches a JWT-SVID or X.509-SVID from the local SPIRE agent, and the server verifies it against the trust domain's keys. In a single cloud, the cloud's workload identity does the same job. The server then authorizes the verified identity per tool.

What is a SPIFFE ID for an AI agent?

A URI that names the agent within your trust domain, such as spiffe://example.org/agent/billing-bot. SPIRE issues it to processes that match a registration entry's selectors, such as a Kubernetes namespace and service account. It is the sub of the agent's JWT-SVIDs and the URI SAN of its X.509-SVIDs, so servers authorize on it.

Should an AI agent use a JWT-SVID or an X.509-SVID?

Use a JWT-SVID for HTTP APIs and MCP servers that authenticate bearer tokens, especially behind gateways that terminate TLS. Request one audience per token and keep the lifetime short, because JWT-SVIDs can be replayed. Use an X.509-SVID for mutual TLS between services you run, where the private key proves the caller and replay is not possible.

Is SPIFFE better than AWS IAM roles or Entra Workload ID for agents?

Not better, different. Cloud workload identity is simpler when an agent runs in one cloud and calls that cloud's services. SPIFFE gives one identity scheme across clouds, on-premises sites and clusters, and it can federate into cloud IAM through its OIDC Discovery Provider. Many platforms use both: SPIFFE inside, cloud identity at the cloud's own APIs.

Do I need SPIRE to try SPIFFE with an agent?

Not to write and test the code. A stand-in that speaks the Workload API, like the one in this guide, lets you run the client, the identity and the server checks locally. You need SPIRE for anything real: attestation, registration entries, key rotation and federation. SPIRE's quickstarts run it in a local Kubernetes cluster.

How does an MCP server verify a SPIFFE identity?

It fetches the trust domain's JWT signing keys as a JWKS, usually from SPIRE's OIDC Discovery Provider, and checks each JWT-SVID's signature, audience and expiry. Then it authorizes the sub claim, which is the SPIFFE ID. In Promptise that is JwksAuth(jwks_url=..., audience=...) with AuthMiddleware, plus RequireClientId on the tools.

How does Promptise use SPIFFE for agent identity?

Promptise reads the agent's JWT-SVID through AgentIdentity, either from a file that spiffe-helper keeps current (AgentIdentity.from_spiffe(token_file=...)) or straight from the Workload API through CallableTokenProvider and py-spiffe. build_agent(identity=...) then presents an SVID minted for each MCP server's audience, and JwksAuth with RequireClientId verifies and authorizes the SPIFFE ID on the server.


[18]

Next steps

  • The general pattern, with Entra, AWS and Google Cloud providers: AI agent identity and authentication.

  • Protect the server side: MCP server authentication with JWT and OAuth.

  • Harden the tools agents call: MCP security best practices.

  • Keep a verifiable record of agent actions: AI agent audit logs.

  • Separate tenants and their agents: multi-tenant AI agents.

  • Reference: SPIFFE overview, SPIRE concepts, JWT-SVID specification, py-spiffe and Promptise SPIFFE provider.

Contents

  1. 01How do you use SPIFFE for AI agents?
  2. 02What are SPIFFE and SPIRE?
  3. 03Why do AI agents need workload identity instead of shared API keys?
  4. 04Should an agent use an X.509-SVID or a JWT-SVID?
  5. 05How does SPIFFE compare with cloud workload identity?
  6. 06When to use SPIFFE for agents (and when not)
  7. 07What you need
  8. 08Step 1: Start a stand-in Workload API
  9. 09Step 2: Fetch and validate a JWT-SVID
  10. 10Step 3: Give a Promptise agent its SPIFFE identity
  11. 11Step 4: Verify SVIDs on an MCP server
  12. 12Step 5: Run the agent against the server
  13. 13The complete example
  14. 14Running it in production with SPIRE
  15. 15Common problems and fixes
  16. 16Limitations
  17. 17Frequently asked questions
  18. 18Next steps

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

> Guardrails and security

Related guides

All guides in this topic →
  • // 01Advanced · 18 minMulti-Tenant AI Agents: Keep Each Customer's Data ApartBuild a multi-tenant AI agent in Python: per-user tokens, tenant-scoped MCP tools and isolated sessions, with runs proving one tenant can't read another.
  • // 02Intermediate · 15 minAI Agent Audit Logging: Tamper-Evident Logs in PythonLog every AI agent tool call to an HMAC-chained file, then catch edits, deletions and reordering with one command. Real tamper tests, real output.
  • // 03Intermediate · 20 minMCP Security Best Practices: A Checklist for MCP ServersMCP security best practices as a checklist: token audience binding, per-tool scopes, input validation, human approval, rate limits and signed audit logs.
  • // 04Intermediate · 17 minAI Agent Production Checklist: What to Check Before LaunchAn AI agent production checklist: security, approvals, guardrails, limits, cost, observability, evals, deployment and incident response, with code.
  • // 05Intermediate · 21 minApproval Fatigue: Auto-Approve Safe AI Agent ActionsStop rubber-stamping AI agent actions. Auto-approve routine tool calls with rules, send only risky ones to a human, and log why. Real Python runs.
  • // 06Intermediate · 9 minLLM Input Validation: Why Regex Fails and What WorksValidate LLM input in layers: limits, Unicode normalization, field rules and a scope classifier. Real examples of regex failing and what catches attacks.

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths