> For the complete documentation index, see [llms.txt](https://docs.p0.dev/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.p0.dev/agentic-access/using-the-gateway/spiffe-svid.md).

# JWT-SVID

Enroll a SPIFFE identity provider with P0, and authenticate an unattended workload to the P0 AI Gateway with an existing JWT-SVID from Python.

This guide covers authenticating an unattended agent to the P0 AI Gateway, part of [P0 Runtime Access for AI](/readme/agentic-control-plane.md), with a [SPIFFE JWT-SVID](https://spiffe.io/docs/latest/spiffe-specs/jwt-svid/): enrolling the identity provider that issues your JWT-SVIDs, setting the audience values correctly, and presenting a JWT-SVID from Python.

## 1. Overview

### When to use JWT-SVID authentication

Use JWT-SVID authentication when your agent runs as a **workload rather than on behalf of a person**: a service, a batch job, a pod in your cluster. There is no browser and no one to sign in, so the agent authenticates as itself using the identity your SPIFFE infrastructure already gives it.

If a person is present and the agent acts for them, use the client ID and secret path in [Connect an MCP client](/agentic-access/using-the-gateway/connect-an-mcp-client.md) instead.

### What this guide assumes

This guide assumes **your workload can already obtain a JWT-SVID**. Issuing, minting, rotating, and mounting SVIDs is the job of your SPIFFE infrastructure, and is out of scope here. What follows covers only what P0 requires.

An SVID is a SPIFFE-compatible format for presenting a **SPIFFE ID**, the identifier for a workload. SPIFFE defines several SVID formats, each with its own conventions. In a JWT-SVID the SPIFFE ID is carried as the `sub` claim.

{% hint style="warning" %}
P0 requires the **JWT-SVID** format. SPIFFE also defines X509-SVID and WIT-SVID, and neither can be presented here. If your platform issues X509-SVIDs by default, or exchanges them through an internal service, you must obtain the JWT-SVID form for use with the gateway.
{% endhint %}

### What must already exist

* A deployed P0 AI Gateway and a registered [Gateway](/agentic-access/agentic-gateway/gateway.md) component.
* At least one [MCP server](/agentic-access/agentic-gateway/mcp-server.md) behind that gateway. Note its server identifier.
* A JWT-SVID available to your workload, and the **issuer URL** of whatever issues it. The issuer must serve an OpenID Connect discovery document whose issuer value matches your configured issuer exactly, and which advertises a `jwks_uri` where the signing keys are published. The Auth Server checks two locations for that document and accepts whichever one returns a document that declares your registered issuer:

  * the RFC 8414 inserted form, `{origin}/.well-known/openid-configuration{issuer-path}`, which inserts the `.well-known` segment between the origin and any issuer path
  * the OIDC Discovery appended form, `{issuer}/.well-known/openid-configuration`, which appends the segment to the full issuer

  Okta custom authorization servers and PingOne environments publish at the appended form, so an Okta-style issuer that carries a path is still supported. The `jwks_uri` may be on a different host. **The Auth Server** fetches the discovery document and the `jwks_uri`, so the issuer must be reachable from it over HTTPS at a publicly resolvable address. Private ranges and redirects are both rejected.
* An issuer that signs with **RS256 or ES256**. These are the only algorithms a gateway accepts by default.
* Python 3.10 or later.

## 2. How the authentication flow works

Four steps, of which your code performs two:

1. **Your workload obtains a JWT-SVID.** Handled by your SPIFFE infrastructure, typically written to a file that a sidecar or CSI driver keeps rotated. Your code reads it.
2. **Your workload presents the JWT-SVID to P0.** It posts the JWT-SVID to the gateway's Auth Server token endpoint as a JWT bearer assertion, naming the MCP server it wants to reach.
3. **P0 validates it.** The Auth Server verifies the JWT-SVID's signature against the issuer's published keys, checks the JWT-SVID's audience, and asks P0 whether the issuer is enrolled and whether the token's audience and subject satisfy that enrollment's patterns.
4. **The gateway's Auth Server issues an access token.** On success the Auth Server returns a short-lived access token scoped to that one MCP server. Your client presents that token, not the JWT-SVID, on every MCP request.

The important consequence of step 4: **the JWT-SVID is exchanged once for an access token, and the access token is what the gateway sees.** These are two different tokens, and the `Authorization: Bearer` header on your MCP requests carries the access token.

## 3. Enroll the SPIFFE identity provider in P0

Enrolling an identity provider tells P0 that JWTs from a given issuer may authenticate agents to the gateway. Follow [Identity provider](/agentic-access/identity-provider.md) to enroll one, then set its fields for a JWT-SVID as follows.

| Field                | What it must match for a JWT-SVID                                                                                                             |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| **Issuer**           | The issuer URL of your SPIFFE infrastructure, matching the JWT-SVID's `iss` claim. Compared for exact string equality, never as a pattern.    |
| **Audience pattern** | The JWT-SVID's `aud` claim, which must be the gateway hostname. See [#id-4.-configure-the-audience](#id-4.-configure-the-audience "mention"). |
| **Subject pattern**  | The JWT-SVID's `sub` claim, which is the workload's SPIFFE ID, for example `spiffe://example.org/ns/prod/sa/my-agent`.                        |

{% hint style="danger" %}
**Audience pattern and subject pattern are regular expressions, and they are not anchored for you.** They are compiled as regular expressions and tested against the claim, so a pattern matches anywhere in the value unless you anchor it.

`spiffe://example.org` as a subject pattern also matches `spiffe://example.org.attacker.example/agent`, because the pattern is unanchored and `.` matches any character. Anchor both ends and escape dots:

* Too permissive: `spiffe://example.org`
* Correct: `^spiffe://example\.org/.+$`

Glob syntax does not work. A bare `*` is not a wildcard here and is not a valid regular expression on its own.
{% endhint %}

### What this configuration is, and is not

{% hint style="info" %}
The identity provider configuration is a **coarse authentication filter, not a set of per-agent rules.** It establishes that an issuer is trusted and that a token's audience and subject fall within accepted patterns. It does not say which agent may do what.
{% endhint %}

Binding a verified identity to a specific agent happens on the client registration, not here. See [Identity provider](/agentic-access/identity-provider.md#register-an-agent-identity). Authorization over tool calls lives in [Agentic access policies](/policy-studio/policy-studio/agentic-access-policies.md). Do not attempt to restrict individual agents in the identity provider's patterns.

Whether the provider automatically registers a matching agent depends on the provider's **Dynamic registration** setting. With it disabled, you must register each agent identity before P0 accepts that agent's JWT-SVID.

The check is also **tenant-wide**. A token passes if *any* enrolled identity provider matches it.

{% hint style="info" %}
To manage AI gateway configuration as code, use the P0 Terraform provider's [`p0_ai_gateway`](https://registry.terraform.io/providers/p0-security/p0/latest/docs/resources/ai_gateway) and [`p0_ai_gateway_staged`](https://registry.terraform.io/providers/p0-security/p0/latest/docs/resources/ai_gateway_staged) resources.
{% endhint %}

## 4. Configure the audience

**There are two different audience values in this flow, and they're not the same.** Setting both to the same value is the most common reason a correctly issued JWT-SVID is rejected.

| Audience                                          | Value                                                           | Where it is set                                                                                                                                         |
| ------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **The JWT-SVID's own `aud` claim**                | The gateway hostname, for example `https://gateway.example.com` | Set by whatever issues the JWT-SVID. Must match the Auth Server's configured assertion audience, and must satisfy the **Audience pattern** from step 3. |
| **The `audience` parameter on the token request** | The per-server URL, `{gatewayUrl}/mcps/{serverId}`              | Set by your code, in the token request in step 5.                                                                                                       |

{% hint style="danger" %}
The JWT-SVID's audience is the **gateway hostname**. The token request's audience is the **specific MCP server URL**, which is the gateway hostname suffixed with the `/mcps/{serverId}` path.

Using the bare hostname as the token request audience yields an access token the gateway's data plane rejects. Using the per-server URL as the JWT-SVID audience causes validation to fail before a token is ever issued.
{% endhint %}

So for a gateway at `https://gateway.example.com` serving a server with identifier `gcp-compute`:

* The JWT-SVID must be issued with `aud` of `https://gateway.example.com`.
* The **Audience pattern** in step 3 must match that, for example `^https://gateway\.example\.com$`.
* The token request must send `audience=https://gateway.example.com/mcps/gcp-compute`.

## 5. Connect from Python

```bash
pip install mcp
```

The MCP SDK installs `httpx`, so the token request needs no extra dependency.

{% hint style="info" %}
The following snippet targets MCP Python SDK **v2**, whose import paths differ from v1. In v2 the transport helper is `streamable_http_client`, not `streamablehttp_client`. Check your installed version with `pip show mcp`.
{% endhint %}

### Reading the JWT-SVID

Read the JWT-SVID from wherever your SPIFFE infrastructure puts it, commonly a file kept rotated by a sidecar or CSI driver. Read it fresh on each exchange rather than caching it at startup, so a rotated JWT-SVID is picked up.

### The client

```python
import asyncio
import os
import pathlib

import httpx
from mcp import ClientSession
from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client

GATEWAY_URL = "https://gateway.example.com"
SERVER_ID = "gcp-compute"
SVID_PATH = os.environ.get("SPIFFE_JWT_SVID_PATH", "/run/spiffe-jwt/svid.jwt")

JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer"

# The MCP server URL, which is also the audience the access token must carry.
SERVER_URL = f"{GATEWAY_URL}/mcps/{SERVER_ID}"


def read_svid() -> str:
    """Reads the JWT-SVID your SPIFFE infrastructure wrote."""
    svid = pathlib.Path(SVID_PATH).read_text().strip()
    if not svid:
        raise RuntimeError(f"the JWT-SVID at {SVID_PATH} is empty")
    return svid


async def fetch_access_token() -> str:
    """Exchanges the JWT-SVID for an access token scoped to one MCP server.

    The audience here is the per-server URL, not the bare gateway hostname.
    Client authentication is optional for this grant, so no client_id or
    client_secret is sent.
    """
    async with httpx.AsyncClient() as http:
        response = await http.post(
            f"{GATEWAY_URL}/token",
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            data={
                "grant_type": JWT_BEARER_GRANT,
                "assertion": read_svid(),
                "audience": SERVER_URL,
            },
        )

    if response.status_code != 200:
        raise RuntimeError(
            f"token exchange failed: {response.status_code} {response.text}"
        )
    if "json" not in response.headers.get("content-type", ""):
        raise RuntimeError(
            f"{GATEWAY_URL}/token did not return JSON. Is this the OAuth "
            f"server, rather than an SSO proxy or the console front end?"
        )

    token = response.json().get("access_token")
    if not token:
        raise RuntimeError("the Auth Server returned no access_token")
    return token


async def main() -> None:
    access_token = await fetch_access_token()

    async with create_mcp_http_client(
        headers={"Authorization": f"Bearer {access_token}"}
    ) as http_client:
        async with streamable_http_client(SERVER_URL, http_client=http_client) as (
            read,
            write,
        ):
            async with ClientSession(read, write) as session:
                await session.initialize()

                tools = await session.list_tools()
                print("tools:", [tool.name for tool in tools.tools])


asyncio.run(main())
```

{% hint style="warning" %}
The gateway and its Auth Server must be reachable at the same URL. Splitting them across separate hosts is not supported yet, so the token endpoint and the MCP server URL share the `GATEWAY_URL` host above.
{% endhint %}

Access tokens are short-lived, and this snippet obtains one and holds it, which is enough for a single short session. A long-running agent needs more: set the `Authorization` header per request rather than once on the client, so that when the gateway answers `401` you can exchange a fresh JWT-SVID and retry with the new access token. Reading the SVID file on each exchange, as `read_svid()` does, is what makes that possible.

## 6. Verify the connection

1. **Confirm the exchange.** `fetch_access_token()` returns without raising. A failure here is a JWT-SVID or identity provider problem, not an MCP problem.
2. **Inspect the access token's subject.** Decode the returned token. Its `sub` is a subject P0 derives, of the form `federated/{providerId}/subject/{jwtSvidSub}`, not your workload's SPIFFE ID. Confirm the `providerId` names the identity provider you enrolled in step 3. The SVID's own `sub` appears in the last `{jwtSvidSub}` segment.
3. **Confirm the session.** `list_tools()` returns the server's tools.
4. **Confirm P0 saw it.** The `list_tools` call appears in the gateway's activity at `GET /o/{orgId}/agentic/activity`.

## 7. Troubleshooting

The token endpoint answers a failed exchange in one of three ways. Identify which before working through the table:

| Response                      | Meaning                                                                                                      |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `400 invalid_request`         | The request is malformed. The `error_description` names the problem.                                         |
| `400 invalid_grant`           | The JWT-SVID failed validation. Deliberately opaque: every check returns this same response.                 |
| `503 temporarily_unavailable` | The gateway could not reach P0. Nothing is wrong with your JWT-SVID. Check the gateway's connectivity to P0. |

### Finding out which check failed

`invalid_grant` never says what went wrong, but the gateway runs in your own environment, so its logs do. In the Auth Server's logs, find the audit event `auth.jwt_bearer_grant.assertion.outcome` with `outcome=denied`. Its `reason` attribute names the exact check that failed, for example `assertion_expired`, `assertion_lifetime_exceeded`, `issuer_not_registered`, or `signature_or_registered_key_invalid`.

`signature_or_registered_key_invalid` is the broadest of these: it covers a bad signature, a wrong `aud`, an expired token, a disallowed algorithm, and every issuer-discovery failure. When you see it, the adjacent warning `federated assertion signature verification failed` carries the underlying error, which distinguishes them.

Work through the following table in order.

| Symptom                                                                                  | Cause                                                                                                                                                     | Fix                                                                                                                                                                                                                                                                                                                                                          |
| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| The token is a certificate, or has no `spiffe://` subject                                | The SVID is in X509-SVID form, not JWT-SVID form                                                                                                          | Obtain the JWT-SVID. P0 cannot accept an X509-SVID.                                                                                                                                                                                                                                                                                                          |
| `invalid_grant`, and the JWT-SVID's `aud` is the per-server URL                          | Wrong JWT-SVID audience                                                                                                                                   | The JWT-SVID's `aud` must be the **gateway hostname**. See [#id-4.-configure-the-audience](#id-4.-configure-the-audience "mention").                                                                                                                                                                                                                         |
| Exchange succeeds, then the gateway answers `401 invalid_token` on the first MCP request | Wrong token request audience                                                                                                                              | Nothing validates the `audience` parameter at the token endpoint, so a wrong value still returns `200` and a well-formed token. It fails only when the gateway checks the token's `aud`. The value must be exactly `{gatewayUrl}/mcps/{serverId}`, with no trailing slash. Omitting `audience` entirely produces a token that can never reach an MCP server. |
| `invalid_grant`, audience and issuer both look right                                     | **Subject pattern** does not match the JWT-SVID's SPIFFE ID                                                                                               | Test the pattern against the actual `sub`. Remember it is a regular expression: anchor it and escape dots. An over-anchored or mistyped pattern fails silently.                                                                                                                                                                                              |
| `invalid_grant`, and the issuer looks right                                              | The issuer is not enrolled, or does not match exactly                                                                                                     | **Issuer** is compared for exact string equality against `iss`. A trailing slash or an `http` versus `https` mismatch fails.                                                                                                                                                                                                                                 |
| `invalid_grant` on a freshly issued JWT-SVID                                             | The JWT-SVID has expired, or its lifetime exceeds what the gateway accepts                                                                                | Check `exp`. The gateway rejects assertions whose remaining lifetime is too long, so a long-lived JWT-SVID fails even before expiry. Gateways deployed with the Helm chart cap this at **300 seconds**, which exactly matches SPIRE's default `jwt_svid_ttl` and leaves no margin. Either shorten the issuer's TTL or raise the gateway's limit above it.    |
| `invalid_grant`, and everything else checks out                                          | The issuer signs with an algorithm the gateway does not accept                                                                                            | Only **RS256** and **ES256** are accepted by default. SPIRE configured for ES384, ES512, or EdDSA fails here with no distinguishing error. Check your issuer's key type.                                                                                                                                                                                     |
| `503 temporarily_unavailable`                                                            | The gateway cannot reach P0                                                                                                                               | Not a JWT-SVID problem. Check the gateway's network path to P0 and its P0 connection settings.                                                                                                                                                                                                                                                               |
| `invalid_client`, or `invalid_request` mentioning client authentication                  | The token request included client credentials that are wrong or incomplete. Gateways older than 1.1.0-beta reject any client credentials with this grant. | Client credentials are optional with the JWT bearer grant, so the simplest fix is to send only `grant_type`, `assertion`, and `audience`. If you do send them, send `client_id` with either `client_secret` or a client assertion, not both, for a client registered with P0.                                                                                |
| A `2xx` response that is not JSON                                                        | Pointing at an SSO proxy or the console front end, not the Auth Server                                                                                    | Use the gateway's Auth Server host. A proxy answers `200` with an HTML login page.                                                                                                                                                                                                                                                                           |
| `404` on an API call                                                                     | Used `mcp` as the integration key, or prefixed the read endpoints with `integrations/`                                                                    | The integration key is `agentic`. Server and client reads live at `/o/{orgId}/agentic/...`. Component configuration lives at `/o/{orgId}/integrations/agentic/config/...`.                                                                                                                                                                                   |

## Related

* [Connect an MCP client](/agentic-access/using-the-gateway/connect-an-mcp-client.md): the user-delegated alternative to this guide. It also covers configuring the MCP server, which both paths need.
* [Identity provider](/agentic-access/identity-provider.md): enroll the issuer that mints your JWT-SVIDs.
* [Gateway](/agentic-access/agentic-gateway/gateway.md): register a gateway deployment, including its OAuth server endpoint.
* [Agentic access policies](/policy-studio/policy-studio/agentic-access-policies.md): govern what an authenticated agent may do.
* [SPIFFE specification](https://github.com/spiffe/spiffe/blob/main/standards/JWT-SVID.md): the JWT-SVID standard.
