Skip to main content
Glama
tanya-ok

mcp-svid-notes

by tanya-ok

mcp-svid-auth

A checked proof of concept: an AI agent exchanges its SPIFFE workload identity for a short-lived, audience-bound MCP access token. The demo shows both enforced security boundaries and the failures identity alone cannot prevent.

Documentation: https://tanya-ok.github.io/mcp-svid-auth/

Problem

MCP client configs often hold long-lived static keys:

{ "env": { "NOTES_API_KEY": "sk-live-..." } }

Issue with static keys

Effect

Long-lived

A leaked key works until someone rotates it by hand

Not bound to a workload

Any process that reads the file can use it

Not bound to a server

One key often opens several servers

No per-caller identity

Audit logs cannot say which agent acted

Related MCP server: MCP Credentials Broker

What this POC shows

Claim

Where

An agent gets a short-lived access token using only its JWT-SVID

authz.py, draft-ietf-oauth-spiffe-client-auth

Tokens are bound to one MCP server via the RFC 8707 resource parameter

authz.py, policy.py

A token issued for server A is rejected by server B

mcp_server.py, test_token_for_a_is_rejected_by_b

Scopes are enforced per tool, with a 403 insufficient_scope challenge. Tools without a declared scope are denied.

ToolScopeGuard, build_mcp

Decisions are hash-chained; a separately trusted terminal checkpoint detects suffix deletion and chain replacement. Without a checkpoint, mcp-svid-audit-verify detects edits but not deletion of lines from the end

audit.py

The MCP server never forwards the incoming token. With an upstream it uses its own token

Upstream in mcp_server.py, test_no_passthrough.py

A stdio MCP server can start with a refreshed short-lived token instead of a static key

stdio_wrapper.py

Targets the MCP authorization spec, revision 2026-07-28 (checked 2026-10-04): Protected Resource Metadata (RFC 9728), resource indicators (RFC 8707), audience validation, no token passthrough.

Client authentication implements the JWT-SVID exchange from draft-ietf-oauth-spiffe-client-auth-02: assertion type urn:ietf:params:oauth:client-assertion-type:jwt-spiffe, advertised as spiffe_jwt in token_endpoint_auth_methods_supported. This is a supported subset, with a deliberate local Workload API bundle-lookup deviation. See the compatibility matrix for draft status, generic OAuth differences and the independently checked TypeScript adapter.

Architecture

sequenceDiagram
    participant SA as SPIRE agent<br/>(Workload API)
    participant AG as agent/research
    participant AZ as authz
    participant A as notes-a (MCP)
    participant B as notes-b (MCP)

    AG->>A: GET /.well-known/oauth-protected-resource/mcp
    A-->>AG: resource, authorization_servers
    AG->>AZ: GET /.well-known/oauth-authorization-server
    AZ-->>AG: issuer, token_endpoint
    AG->>SA: FetchJWTSVID(aud = issuer)
    SA-->>AG: JWT-SVID (60s demo; max 5 min)
    AG->>AZ: POST /token client_credentials<br/>client_assertion = JWT-SVID<br/>resource = notes-a
    AZ->>SA: FetchJWTBundles
    AZ->>AZ: verify SVID, check policy.yaml
    AZ-->>AG: access token (aud = notes-a, 5 min)
    AG->>A: tools/call + Bearer token
    A->>A: verify sig, iss, exp, aud, tool scope, write audit line
    A-->>AG: result
    AG-->>B: same token replayed
    B-->>AG: 401 invalid_token (aud mismatch)

Components

Module

Command

Role

authz

mcp-svid-authz

Token endpoint. JWT-SVID client assertion in, audience-bound JWT access token out. Publishes JWKS and RFC 8414 metadata.

mcp_server

mcp-svid-notes

MCP server over Streamable HTTP. Tools notes.search (notes:read) and notes.write (notes:write); with --upstream-resource also notes.search_upstream (notes:read), which calls another MCP server with the server's own token.

agent_client

mcp-svid-agent

Discovers the authorization server, refuses it unless it is on the --trusted-issuer allowlist, fetches its SVID, gets a token, calls tools. --steal-token replays a token against another server.

stdio_wrapper

mcp-svid-stdio

Starts a local stdio MCP server with MCP_ACCESS_TOKEN_FILE (0600, refreshed). Fails closed on expiry. --export-token-env also sets MCP_ACCESS_TOKEN and stops the child when it expires (weaker, see below).

deploy/

make demo

SPIRE server and agent 1.15.3 (pinned by digest), registration entries, the services, eighteen asserted Kubernetes scenarios and a safe verdict report.

Token request, as sent by the agent:

Parameter

Value

grant_type

client_credentials

client_assertion_type

urn:ietf:params:oauth:client-assertion-type:jwt-spiffe

client_assertion

JWT-SVID, aud = authz issuer only, iat required, exp - iat at most 300s (--max-svid-lifetime)

client_id

the SPIFFE ID (optional, must match the SVID sub)

resource

canonical URI of the MCP server (required)

scope

required, single-space separated, must be a subset of the policy grant

Repeated parameters are rejected with invalid_request. Error descriptions are fixed strings; audit diagnostics retain exception class names, not exception text. Unverified subjects and SVID IDs are hashed before logging.

The MCP server accepts only access tokens with header typ at+jwt, algorithm ES256, and claims iss, sub, aud, exp, iat, scope. Request bodies are capped at 1 MiB (413). Bodies that are not valid JSON-RPC get 400.

Quickstart

The single-repository path needs only Python 3.12+ and uv, plus Node 26+ and npm for the interoperability check. It uses runtime local keys instead of SPIRE and contacts no external service after dependencies are installed.

uv sync --locked
make check      # ruff, mypy strict, pytest (offline, no SPIRE needed)
(cd examples/typescript-client && npm ci --ignore-scripts --no-audit --no-fund)
make interop    # local authz and notes server on loopback, driven by the TypeScript MCP SDK
make package    # local wheel + source archive; no publication

make interop is the local end-to-end demo without the lab: discovery, JWT-SVID exchange, MCP initialization, scoped tool listing, an allowed read and a denied write. Offline tests use a local key as a stand-in for SPIRE (LocalSvidIssuer). mcp-svid-authz --static-jwks accepts a JWKS file for the same purpose.

Optional local anonymization denylist: put one term per line in tests/denylist.txt (git-ignored). tests/test_no_identifiers.py then fails on any match in the tree or history.

Full Kubernetes lab (requires unpublished components)

The integrated lab is not reproducible from this repository alone. It needs a sibling checkout of mcp-spiffe-bridge, which is not yet published, and of hipaa-fhir-mcp. On macOS it also needs Lima 2.2.1+. The demo scenarios below were recorded in that lab. See shared security lab and CNCF runtime.

make runtime-up   # dedicated Lima Kubernetes/containerd VM: 2 CPUs, 4 GiB memory
make demo         # fresh lab stack; includes real expiry and negative cases
make down
make runtime-stop # release VM CPU/memory only when no sibling project needs it

For the lab's read-only interface, run make ui and open Security lab. It shows services, trust relationships, permissions, safe audit outcomes and the latest demo verdicts. See Headlamp interface for access limits and shutdown commands.

make down removes only owned Security Lab resources. If another project shares this VM, retain the VM instead of running make runtime-stop. IT GRC (it-grc, not yet public) runs CISO Assistant Community edition in its own Lima VM, not in this lab, and takes the lab's demo report as its input.

Demo scenarios

Each scenario asserts its outcome. An unexpected exit, missing evidence or wrong result makes make demo fail. It writes a credential-free report to ignored deploy/.data/demo-report.json; the expiry probe uses 60-second SVIDs and includes the 30-second validation leeway. See demo details.

#

Caller

Target

Expected

1

agent/research

notes-a

token with notes:read notes:write, both tools allowed

2

agent/research

notes-b

token with notes:read, notes.write gets 403 insufficient_scope

3

agent/intruder

notes-a

valid SVID, not in policy.yaml, token request fails with unauthorized_client

4

agent/research

token for notes-a sent to notes-b

401 invalid_token, audit reason InvalidAudienceError

5

agent-impostor pod with the research label and another ServiceAccount

notes-a

gets the intruder identity; token request denied

6

agent/research

notes-rogue, whose PRM names http://rogue-as:8100

issuer_refused, no SVID fetched (LS-3)

7

agent/research, one JWT-SVID posted twice to /token

authz

two tokens under allow-reuse-within-lifetime (LS-9)

8

agent/research calls notes.search_upstream

notes-a, which calls notes-b

notes-b audits mcp/notes-a, not the caller; scenario 4 is the forwarding variant (LS-4)

9

agent/research, grant for notes-b removed while authz runs

notes-b

invalid_target after the next policy reload (LS-8)

10

Deterministic injected write request

notes-a

read-only token denied; read-write token succeeds: identity does not determine intent

11

agent/research after restart and planned rotation

notes-a

restart key accepted, then prepublished rotation preserves old and new tokens during overlap (LS-10)

12

Research workload through initial SVID expiry

authz and notes-a

renewed SVID remains usable; expired original assertion refused

13

One SVID used twice under default strict mode

authz-strict

first token issued, second exchange denied

14

Audit capture, then delete its final record

local verifier

complete chain passes; deletion fails against saved checkpoint

15

Research workload with SPIRE mTLS and OAuth

FHIR service

Three tools succeed; wrong audience, missing bearer and bad/unknown requests denied

16

Intruder with valid SPIRE certificate

FHIR bridge

SPIFFE caller allowlist denies access

17

Workload without client certificate

FHIR bridge

TLS handshake denied

18

FHIR audit checker

Service log

One record per invocation, verified caller, data minimization

Audit line example:

{"record_id":"5f0c9a53-2a8e-4d0b-9a57-0f3e2f6d1c44","parent_record_id":"b1d7e0a2-6c1f-4e89-8f0e-2d4c7a9b3e51","prev_hash":"3b9f0c2e8d7a41f6b5e0c9d8a7f6e5d4c3b2a1908f7e6d5c4b3a29180f7e6d5c","timestamp":"2026-09-26T18:07:11.742+00:00","component":"mcp_server:notes-b","spiffe_id":"spiffe://example.org/agent/research","tool":"notes.write","decision":"deny","reason":"insufficient_scope: needs notes:write"}

Denials record unverified subjects as unverified:sha256:<digest prefix>. A checkpoint must be retained independently of the log writer; see audit integrity.

Threat model notes

Threat

Mitigation here

Gap

Static key leak

No static keys. Access tokens live 5 min; SVID maximum is 5 min, with 60s SVIDs in the demo.

A stolen access token works until expiry.

Token replay to another server

aud bound to one resource. Each server checks aud equals its own URI.

None within one issuer.

JWT-SVID replay to authz

SVID aud must be the issuer only. Maximum SVID lifetime 5 min; demo TTL 60s. Default --svid-replay reject: jti required, each (sub, jti) accepted once.

The container demo runs allow-reuse-within-lifetime because the SPIRE 1.15.3 agent re-serves cached SVIDs; there a stolen SVID mints tokens until it expires. See docs/security.md.

SVID harvesting by a malicious resource

The agent mints SVIDs only for issuers on its --trusted-issuer allowlist. An issuer named in Protected Resource Metadata that is not on the list is refused, and logged as issuer_refused, before any request to it and before any SVID fetch. Exact match after scheme, host and default port normalization; no prefix match.

The allowlist is per process, not per resource.

Discovery SSRF

Every URL the agent fetches or sends a credential to (resource, issuer, token_endpoint, tool call) must be https and resolve only to public addresses. Refused URLs are logged as url_refused and get no request.

--allow-http and --allow-private-network turn the checks off for dev; the container demo needs both. The default transport connects to a checked numeric address, preserves Host and TLS hostname verification, separates connection pools by original origin and ignores proxy environment variables. Custom HTTP factories must provide equivalent protection.

Workload impersonation

SPIRE Kubernetes attestor binds identity to namespace and ServiceAccount. Copied labels do not suffice.

Cluster operators and anyone allowed to use that ServiceAccount can assume its identity.

Over-broad access

Allowlist per SPIFFE ID, resource and scope. Per-tool scope check. Policy reloaded on SIGHUP or file change.

Policy is a local file. No versioning or review flow. Issued tokens outlive a revoked grant by up to 300s.

Confused deputy

Server never forwards the incoming token. An upstream call uses the server's own SVID-based token (notes.search_upstream), tested in test_no_passthrough.py.

No delegation chain: the upstream sees the relay, not the original caller.

Algorithm confusion

SVIDs: asymmetric algorithms only, alg=none rejected. Access tokens: ES256 only, typ at+jwt. Key selected by kid from a trusted key set.

Stale token in a stdio child

Token passed by 0600 file, refreshed. Wrapper deletes it and stops the child on expiry. With --export-token-env the child is stopped when the exported token expires.

--export-token-env values are visible in the process environment to the same UID.

Supply chain

Dependency majors bounded, images pinned by digest, uv.lock committed.

httpx2 is a transitive dependency of mcp 2.x. On PyPI it is owned by Pydantic Services Inc., source github.com/pydantic/httpx2, uploaded via Trusted Publishing (checked 2026-09-26).

Non-goals

  • Not production software. No HA or distributed rate limits; planned in-process key rotation and local cost budgets only. Plain HTTP inside the demo network.

  • Not a replacement for an MCP gateway such as agentgateway. It shows the token flow a gateway could implement.

  • No user delegation. The agent acts as itself (client_credentials). No token exchange or act claim chain yet.

  • The authorization server accepts JWT-SVID client assertions only. The shared FHIR transport also uses X.509-SVID mTLS. No WIT-SVID client authentication.

  • The default Kubernetes lab is single-node and synthetic. Podman compatibility commands were retired on 2026-10-13. See shared lab limits.

  • No jti replay tracking for access tokens.

  • The container demo accepts JWT-SVID reuse within the SVID lifetime (--svid-replay=allow-reuse-within-lifetime). The SPIRE 1.15.3 agent returns the same cached SVID, with the same jti, on repeated fetches before renewal.

Status

The dated publication validation records the checks, artifacts and remaining proof-of-concept limits. Validation runs locally; no release, package upload or deployment is part of it.

Item

State

Offline checks

Pass on 2026-10-13: 537 tests on Python 3.12 and 3.13 after retiring the Podman backend and adding runtime migration checks, lint, formatting, strict types, generated docs and strict documentation build; source/history credential checks have no unresolved findings; see the exact packet-hash false positive in the portfolio readiness record

Kubernetes/containerd integration

Pass on 2026-10-13: fresh rebuild, all 18 scenarios and final scoped teardown. Planned signing rotation preserved old/new tokens; three real SPIRE fetches returned one cached assertion with jti. CISO namespace UID and shared VM preserved. See rotation evidence

Headlamp interface

Pass: five protected desktop views, 390 x 844 layout with visible Refresh, opaque-session logout, idempotent start, shutdown on identity backend loss and recovery. Hosted Login V2 reached the passkey screen without a password form. See interface and boundaries

Optional ZITADEL identity

Pass: 11 real synthetic WebAuthn/OIDC checks, 4 closed Google setup checks and full scoped lifecycle on 2026-10-06. The recovery cycle sampled at most 1858.1 MiB guest use including native startup. Real Google login and physical enrollment remain unverified. See identity boundaries

Historical Podman validation

Retired on 2026-10-13; the 2026-10-05 result remains historical evidence only

Default strict replay mode

Live comparison: first exchange succeeds, second is denied. Normal demo explicitly accepts cached SVID reuse

Independent TypeScript MCP SDK

SDK 1.32.1 with explicit JWT-SVID adapter: discovery, exchange, initialize, scoped listing and read verified; added write denial checked separately

Audit completeness

Terminal checkpoint supported; its protection requires separate trusted storage

Publication packages

Local wheel and source archive, license and contents verified; never uploaded

License

Apache-2.0. See LICENSE and NOTICE.

Development review orchestration

The local Codex and Claude review workflow records source snapshots, independent research, objections and explicit task selection through MCP. A synthetic walkthrough demonstrates the process without model calls. Model agreement is a proposal, not proof of correctness.

Related MCP Connectors

Related MCP Servers

  • F
    license
    Not graded
    quality
    D
    maintenance
    An MCP server for OAuth 2.0 authentication supporting Device Code and Client Credentials flows, enabling secure token management for MCP applications.
    -
  • A
    license
    A
    quality
    D
    maintenance
    Provides secure OAuth2-based credential management for MCP servers, allowing agents to obtain short-lived token references without exposing raw secrets.
    7
    3 npm
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables clients to access multiple backend MCP servers through a single endpoint, with OAuth 2.1 authorization, namespaced tools, and secure credential management.
    2
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables teams to securely store and retrieve credentials, view decrypted secrets or live OAuth tokens, and connect to third-party MCP servers through a unified MCP endpoint.
    MIT