Skip to content

Threat model (STPA-Sec)

This page analyses agent identity for MCP with STPA-Sec: the security application of System-Theoretic Process Analysis. It follows the four steps of the STPA Handbook (Leveson and Thomas, MIT PSAS, 2018): define losses and hazards, model the control structure, identify unsafe control actions, identify loss scenarios. The security framing follows Young and Leveson, An integrated approach to safety and security based on systems theory (CACM 57(2), 2014).

Clauses are cited from the MCP authorization spec, revision 2026-07-28, its Security Considerations, Authorization Server Discovery and Client Registration pages, the Security Best Practices, and draft-ietf-oauth-spiffe-client-auth-02. All checked on 2026-09-27.

Scope: the components in Architecture. The table-level view of threats and known gaps stays in Security model; this page derives them from the control structure.

1. Losses and hazards

Losses

ID Loss
L-1 Data behind an MCP server is disclosed to a party not entitled to it.
L-2 Data behind an MCP server is changed, or an action is taken, without entitlement or intent.
L-3 An action cannot be attributed to the workload that took it.
L-4 Legitimate agent work cannot run (loss of availability).

System-level hazards

ID Hazard Losses
H-1 An MCP server executes a tool call for a principal that is not authorized for that tool on that server. L-1, L-2
H-2 A credential usable at an MCP server or authorization server is held by a party other than the workload it was issued to. L-1, L-2, L-3
H-3 A credential is valid for more resources, scopes or time than the task needs. L-1, L-2
H-4 A decision is recorded without, or with the wrong, verified workload identity. L-3
H-5 A legitimate workload cannot obtain or use a credential for authorized work. L-4
H-6 The MCP client trusts an authorization server or MCP server it was not configured to trust. L-1, L-2
H-7 An authorized workload performs a tool call its user did not intend. L-2

H-5 is kept on purpose. Every fail-closed mechanism in this repository trades H-1 to H-3 for H-5, and the trade should be explicit.

2. Control structure

Controllers are boxes with a role label. Solid arrows are control actions (CA). Dashed arrows are feedback (FB).

flowchart TB
    OP["User / operator<br/>(human controller)"]
    AG["Agent<br/>(LLM planner)"]
    MC["MCP client<br/>(agent_client, stdio_wrapper)"]
    SS["SPIRE server"]
    SA["SPIRE agent<br/>(Workload API)"]
    AZ["Authorization server<br/>(authz)"]
    MS["MCP server<br/>(mcp_server)"]
    ST["stdio MCP server<br/>(child process)"]
    TL[("Tools and data<br/>(controlled process)")]

    OP -- "CA-1 register workload entry" --> SS
    OP -- "CA-2 load grant policy" --> AZ
    OP -- "CA-5 delegate task" --> AG
    SS -- "entries, signing keys" --> SA
    SA -- "CA-3 issue JWT-SVID" --> MC
    SA -- "CA-4 provide JWT bundle" --> AZ
    AG -- "CA-6 request tool call" --> MC
    MC -- "CA-7 token request" --> AZ
    AZ -- "CA-8 issue access token" --> MC
    MC -- "CA-9 tools/call + bearer" --> MS
    MS -- "CA-10 execute tool" --> TL
    MC -- "CA-11 provide credential" --> ST

    MS -. "FB-1 PRM, 401/403 challenges" .-> MC
    AZ -. "FB-2 AS metadata, token errors" .-> MC
    AZ -. "FB-3 JWKS" .-> MS
    MS -. "FB-4 tool results" .-> MC
    MC -. "FB-5 results, errors" .-> AG
    AG -. "FB-6 answer" .-> OP
    AZ -. "FB-7 audit lines" .-> OP
    MS -. "FB-7 audit lines" .-> OP
    SA -. "attestation (selectors)" .-> SS
Controller Controlled process Process model it relies on
User / operator SPIRE entries, policy.yaml, the agent's task Which workload runs where, which task needs which server and scope
Agent Tool selection Task text and tool results, both untrusted input for security decisions
MCP client Credential lifecycle for one resource PRM resource, AS issuer, token expiry
SPIRE agent Identity issuance Kubernetes namespace/ServiceAccount selectors
Authorization server Token issuance SPIRE JWT bundle, grant policy reloaded on SIGHUP or file change
MCP server Tool execution Cached authz JWKS (60s, one refetch on unknown kid at most every 10s), own canonical URI, tool scope map

3. Unsafe control actions

The four STPA types: NP = not provided causes a hazard; P = provided causes a hazard; T = too early, too late or wrong order; D = stopped too soon or applied too long. "n/a" means no hazardous case was found for that type.

CA NP P T D
CA-1 register workload entry UCA-1.1 No entry for a legitimate workload [H-5] UCA-1.2 Entry selectors or ServiceAccount permissions admit workloads beyond the intended owner [H-2]. UCA-1.3 Entry sets a long JWT-SVID TTL [H-3] UCA-1.4 Entry created before namespace and ServiceAccount permissions are under the operator's control [H-2] UCA-1.5 Entry for a retired workload is kept [H-2, H-3]
CA-2 load grant policy UCA-2.1 Grant missing for a legitimate workload [H-5] UCA-2.2 Grant broader than any task needs (all scopes, many resources) [H-3]. UCA-2.3 Grant names the wrong SPIFFE ID or trust domain [H-1] UCA-2.4 Grant revocation takes effect only after already issued tokens expire [H-3] UCA-2.5 Grant kept after the need ends [H-3]
CA-3 issue JWT-SVID UCA-3.1 Workload API unavailable [H-5] UCA-3.2 SVID issued to an attacker process that passes attestation [H-2]. UCA-3.3 SVID aud is not only the AS issuer, so it can be replayed to another verifier [H-2] UCA-3.4 SVID requested for an issuer before that issuer is validated [H-6, H-2] UCA-3.5 SVID lifetime longer than the token request needs [H-2]
CA-4 provide JWT bundle UCA-4.1 Bundle unavailable, AS cannot verify [H-5] UCA-4.2 Keys from a foreign trust domain are used to verify [H-1] UCA-4.3 Stale bundle after key rotation [H-5] UCA-4.4 Removed key kept in the verifier's cache [H-2]
CA-5 delegate task n/a UCA-5.1 Task text (or content the agent reads) carries injected instructions [H-7] n/a UCA-5.2 Agent keeps acting after the user withdraws the task [H-7]
CA-6 request tool call n/a UCA-6.1 Agent requests a tool or arguments derived from untrusted tool output [H-7] n/a n/a
CA-7 token request UCA-7.1 No refresh before expiry [H-5] UCA-7.2 Request without resource or with a broad resource [H-3]. UCA-7.3 Request for more scope than the call needs [H-3]. UCA-7.4 Request sent to an AS named by an untrusted PRM [H-6]. UCA-7.5 Client identifies itself with a Client ID Metadata Document URL and the AS treats it as proof of workload identity [H-2] UCA-7.6 Request made before PRM resource and AS issuer are validated [H-6] n/a
CA-8 issue access token UCA-8.1 AS refuses a valid request [H-5] UCA-8.2 Token issued on an unverified assertion (alg=none, unknown key, foreign trust domain, expired, wrong aud, client_id mismatch) [H-1, H-2]. UCA-8.3 Token aud is not exactly the requested resource [H-3]. UCA-8.4 Token scope exceeds the grant or defaults to all scopes [H-3]. UCA-8.5 Token issued on a replayed SVID [H-2] UCA-8.6 Token issued after the grant was removed from the file but before a restart [H-3] UCA-8.7 Token lifetime longer than the task [H-3]
CA-9 tools/call + bearer n/a UCA-9.1 Token sent to a server other than its audience [H-2]. UCA-9.2 Client sends a token not issued by that server's AS [H-2]. UCA-9.3 Token placed in the URL query [H-2] UCA-9.4 Expired token sent [H-5] n/a
CA-10 execute tool UCA-10.1 Valid token refused because the JWKS cache is stale [H-5] UCA-10.2 Executes with a token for another audience [H-1]. UCA-10.3 Executes with wrong typ, alg or iss [H-1]. UCA-10.4 Executes a tool without a declared scope, or with insufficient scope [H-1]. UCA-10.5 Forwards the incoming token upstream (token passthrough) [H-2]. UCA-10.6 Decision not audited, or audited with an unverified identity [H-4] UCA-10.7 Scope check runs after dispatch, or on a body the dispatcher parses differently [H-1] UCA-10.8 Long-running tool keeps executing after the token expires [H-3]
CA-11 provide credential to stdio server UCA-11.1 No credential, child cannot call upstream [H-5] UCA-11.2 Credential passed through the environment [H-2, H-3]. UCA-11.3 Static long-lived API key passed [H-2, H-3] UCA-11.4 Child started before a credential exists [H-5] UCA-11.5 Token file kept, or child kept running, after the credential expires [H-2]. UCA-11.6 Exported environment value kept after expiry [H-2, H-3]

Mapping to MCP 2026-07-28 clauses

Spec clause Text (short) UCAs
Access Token Privilege Restriction The MCP server "MUST NOT pass through the token it received from the MCP client". UCA-10.5
Token Handling Servers must validate the audience per RFC 8707; clients must not send tokens other than ones issued by the server's AS. UCA-9.1, UCA-9.2, UCA-10.2
Resource Parameter Implementation resource must be in token requests and use the canonical URI. UCA-7.2, UCA-8.3
Protocol Requirements stdio implementations "SHOULD NOT follow this specification, and instead retrieve credentials from the environment". UCA-11.2, UCA-11.3, UCA-11.6
Client ID Metadata Document Security CIMD "cannot prevent localhost URL impersonation by themselves"; AS should consider SSRF; trust policies are optional. UCA-7.5
Authorization Server Discovery The metadata issuer must equal the issuer used to build the well-known URL. AS selection among authorization_servers is the client's responsibility. UCA-7.4, UCA-7.6, UCA-3.4
Token Theft AS "SHOULD issue short-lived access tokens". UCA-8.7, UCA-3.5
Scope Selection Strategy, Scope Minimization Least privilege; 403 insufficient_scope with scope and resource_metadata. UCA-7.3, UCA-8.4, UCA-10.4
Access Token Usage Tokens must not be in the URI query string. UCA-9.3
SSRF Clients fetch resource_metadata, authorization_servers and token_endpoint from possibly malicious servers. UCA-7.4
draft-ietf-oauth-spiffe-client-auth-02, section 3 The JWT-SVID aud contains "only the issuer identifier of the authorization server as its sole value". UCA-3.3, UCA-8.2

4. Loss scenarios

Scenarios of type A explain why a controller provides a UCA (flawed process model, flawed feedback, adversarial input). Type B explain why a correct control action is not followed.

ID Type Scenario UCAs Losses
LS-1 A, adversarial An operator allowed to launch a pod with the research ServiceAccount can obtain that workload identity and tokens within its grant. Copied labels alone are refused by the Kubernetes attestor. The SPIRE agent still has root and host PID access inside the dedicated VM, so platform operators remain trusted. UCA-1.2, UCA-3.2 L-1, L-2, L-3
LS-2 B, adversarial An attacker reads an access token from a log or memory and sends it to a second MCP server of the same issuer. UCA-9.1 L-1
LS-3 A, adversarial A compromised MCP server publishes PRM that names an attacker AS. Without an issuer allowlist the client would fetch an SVID for the attacker issuer and send it there; the SVID cannot be replayed to the honest AS (aud mismatch), but the client would trust tokens and errors from the attacker AS. With --trusted-issuer the client refuses before any request to that AS. The PRM fetch, the token_endpoint and the tool call URL must be https and resolve to public addresses, so a malicious PRM or AS metadata document cannot point the client at internal services; DNS rebinding between the check and the fetch remains. UCA-7.4, UCA-3.4 L-1, L-2
LS-4 A, adversarial An intermediate MCP server that needs an upstream API forwards the caller's token. The upstream accepts it, and its logs show the caller, not the server. Here the relay uses its own token and the upstream rejects a forwarded one (aud mismatch); the upstream audit then names the relay, and which caller it acted for is lost until a delegation chain exists. UCA-10.5 L-1, L-3
LS-5 A, adversarial A local stdio server is launched with an API key in its environment. Any process under the same UID reads it from the process environment; grandchildren inherit it; it never expires. UCA-11.2, UCA-11.3 L-1, L-2
LS-6 A, adversarial A client presents a well-known client's CIMD URL as client_id and a localhost redirect. The AS shows the legitimate client name. The document proves domain control, not which process holds the redirect. UCA-7.5 L-1, L-2
LS-7 A, adversarial A tool result contains instructions. The agent follows them and calls notes.write with attacker content. Every check passes: the workload is authorized, only its intent is wrong. UCA-5.1, UCA-6.1 L-2
LS-8 A The operator removes a grant from policy.yaml. authz reloads the file on SIGHUP or within --policy-poll-seconds (5s), and denies new token requests from then on. Tokens issued before the reload stay valid for up to 5 minutes, because there is no revocation list or introspection. UCA-2.4, UCA-8.6 L-1, L-2
LS-9 A, adversarial A JWT-SVID is stolen within its 5-minute lifetime and replayed at the token endpoint. In --svid-replay reject mode each (sub, jti) is accepted once, so only an unused SVID works, once. In allow-reuse-within-lifetime mode, which the container demo needs because the SPIRE 1.15.3 agent re-serves cached SVIDs, each replay mints a fresh token until the SVID expires. UCA-8.5 L-1, L-2
LS-10 B authz restarts with a new signing key. MCP servers cache the old JWKS for up to 60 seconds. An unknown kid triggers one refetch, at most every 10 seconds, so tokens with the new key are refused for at most 10 seconds. UCA-10.1 L-4
LS-11 A The Workload API or the token endpoint is unreachable during refresh. The stdio wrapper cannot renew, so it deletes the token and stops the child. UCA-7.1, UCA-11.5 L-4 (accepted)
LS-12 A A body with a tool name the scope guard does not recognize, or JSON the guard and the SDK parse differently, reaches dispatch without a scope decision. UCA-10.7, UCA-10.4 L-1, L-2

5. Mitigations in this repository

Links go to the source on main. "Not mitigated" names the matching non-goal or known gap from Security model.

UCA Status Mechanism Evidence
UCA-1.1, 2.1, 3.1, 4.1, 8.1, 11.1 Accepted (fail closed) Missing identity, grant or bundle gives a fixed-string error, never a fallback credential test_bundle_lookup_failure_is_503_without_detail, test_cleanup_when_first_fetch_fails
UCA-1.2, 3.2 Partly Kubernetes attestor binds namespace and ServiceAccount; copied labels alone do not grant identity. Cluster operators and authorized ServiceAccount users remain trusted. No container runtime API socket is mounted. Actual demo scenario 5 denies the copied-label impostor; attestation and boundaries
UCA-1.3, 3.5 Mitigated authz rejects SVIDs with exp - iat above --max-svid-lifetime (300s) and SVIDs without iat authz.py, test_svid_lifetime_above_max_rejected, test_svid_without_iat_rejected
UCA-1.4, 1.5, 2.5 Not mitigated Operator process. Entries and policy have no review or expiry flow Known gap "Over-broad access: policy is a local file"
UCA-2.2 Partly Per SPIFFE ID, per resource, per scope allowlist. Breadth is the operator's choice policy.py, test_resource_not_allowed_for_client
UCA-2.3 Mitigated Trust domain pinned in policy; SPIFFE ID must be allowlisted test_foreign_trust_domain, test_spiffe_id_not_allowlisted, demo scenario 3
UCA-2.4, 8.6 Partly Policy reloaded on SIGHUP and on content change (polled every 5s). New token requests are denied from the reload on; an invalid file denies all. Already issued tokens stay valid until exp (at most 300s); no revocation list or introspection test_removed_grant_denies_new_tokens, test_invalid_file_fails_closed_until_fixed, test_sighup_forces_reload
UCA-3.3 Mitigated authz requires SVID aud to be exactly its issuer test_wrong_svid_audience, test_svid_with_extra_audience_is_rejected
UCA-3.4, 7.4, 7.6 Mitigated Required --trusted-issuer allowlist on the agent and the stdio wrapper. An issuer not on the list is refused before any request to it and before any SVID fetch; exact match after normalization. Client also checks PRM resource and AS metadata issuer. Resource, issuer, token_endpoint and tool call URLs must be https and resolve only to public addresses unless --allow-http or --allow-private-network is set test_untrusted_issuer_refused_before_svid, test_path_issuer_trailing_slash_refused, test_agent_cli_requires_trusted_issuer, test_stdio_cli_requires_trusted_issuer, test_private_resource_refused_before_any_request, test_unsafe_token_endpoint_refused_before_svid, test_call_tool_refuses_unsafe_url_before_sending_token
UCA-4.2 Mitigated Key selected by kid from the policy trust domain bundle only test_svid_signed_by_unknown_key, test_svid_signed_by_wrong_key_with_known_kid
UCA-4.3, 4.4 Partly Workload API mode fetches bundles from SPIRE per request; static JWKS mode is test only spiffe_keys.py
UCA-5.1, 5.2, 6.1 Not mitigated Identity bounds the blast radius (grant, scope, audience), not intent. tools/list filtering hides tools outside the granted scope from the agent, but does not judge intent Non-goal "No user delegation"
UCA-7.1, 11.5 Mitigated stdio wrapper refreshes inside --refresh-margin, then fails closed: deletes the file, stops the child, exits 75 test_refresher_rewrites_before_expiry, test_wrapper_exits_nonzero_when_token_expires
UCA-7.2, 8.3 Mitigated resource required; token carries exactly one aud, trailing slash removed test_missing_resource, test_valid_flow_issues_audience_bound_token
UCA-7.3, 8.4 Mitigated scope required, must be a subset of the grant, no implicit default test_scope_is_required, test_scope_beyond_policy_is_rejected, test_requested_scope_is_narrowed
UCA-7.5 Not applicable authz accepts only client_credentials with the jwt-spiffe assertion type. No CIMD, no DCR, no redirect URIs test_protocol_errors
UCA-8.2 Mitigated Asymmetric alg only, signature, sub, aud, exp, iat, client_id equals sub test_unsigned_assertion_rejected, test_expired_svid, test_client_id_must_match_svid
UCA-8.5 Partly Default --svid-replay reject: jti required, each (sub, jti) accepted once, full cache fails closed. The container demo runs allow-reuse-within-lifetime because the SPIRE 1.15.3 agent re-serves cached SVIDs; reuse is then only visible as svid_jti_sha256 in the audit trail test_replayed_svid_rejected, test_missing_jti_rejected, test_full_cache_fails_closed, test_allow_reuse_accepts_same_svid_and_audits_jti. Known gap "JWT-SVID reuse allowed in the demo"
UCA-8.7 Mitigated Access tokens live 300s authz.py, test_expired_access_token_rejected
UCA-9.1, 10.2 Mitigated Server rejects any aud other than its canonical URI test_token_for_a_is_rejected_by_b, demo scenario 4
UCA-9.2, 10.3 Mitigated iss pinned, typ at+jwt, ES256 only, kid from authz JWKS test_token_from_other_issuer_rejected, test_access_token_without_at_jwt_typ_rejected, test_access_token_algorithm_pinned_to_es256
UCA-9.3 Mitigated Tokens sent only in the Authorization header; PRM advertises bearer_methods_supported = header agent_client.py, test_protected_resource_metadata
UCA-9.4 Accepted Expired token gets 401 invalid_token; client fetches a new one test_expired_access_token_rejected
UCA-10.1 Mitigated JWKS fetched asynchronously and cached 60s. Unknown kid triggers one refetch, at most every 10s; JWKS URL comes only from --jwks-uri; fetch failure fails closed test_unknown_kid_refetches_once_and_accepts_rotated_key, test_refetch_is_rate_limited, test_fetch_failure_denies_without_raising
UCA-10.4, 10.7 Mitigated Scope guard parses the body before dispatch; unknown tools and unparseable bodies get 400; server refuses to start if a tool has no scope; tools/list shows only tools whose scope was granted test_unknown_tool_denied, test_unparseable_body_rejected, test_every_registered_tool_declares_a_scope, test_tools_list_filtered_by_scope, test_scope_enforced_per_tool
UCA-10.5 Mitigated Security invariant 3. With --upstream-resource, notes.search_upstream calls the upstream with a token the server gets with its own JWT-SVID (Upstream), never the caller's. A test records every request leaving the server and checks that none carries the caller token; the caller token presented upstream gets 401. No act chain, so the upstream sees the server, not the original caller test_upstream_call_never_carries_caller_token, test_forwarded_caller_token_is_rejected_upstream. Non-goal "No token exchange or act claim chain yet"
UCA-10.6 Mitigated One audit line per decision; pre-verification identity hashed with an unverified:sha256: prefix. Lines are hash-chained and mcp-svid-audit-verify detects edited, deleted or reordered lines; a separately trusted terminal checkpoint also detects suffix deletion and chain replacement; without one, tail truncation is not detected; no signatures test_authz_audit_lines, test_token_for_a_is_rejected_by_b, test_edited_record_is_detected, test_deleted_record_is_detected
UCA-10.8 Partly The scope guard stops waiting for a tools/call at the token exp and answers 401 invalid_token (audit token_expired_during_call). A sync tool keeps running on its worker thread, so notes.write re-checks expiry right before it changes state and refuses after exp. A tool without that re-check can still finish its side effect late test_call_outliving_token_gets_401, test_write_refuses_after_expiry_without_side_effect
UCA-11.2, 11.3 Mitigated (default) Wrapper passes a 0600 token file, removes inherited MCP_ACCESS_TOKEN. This deviates on purpose from the stdio SHOULD in the spec test_token_file_is_owner_only, test_child_gets_only_token_file_by_default
UCA-11.4 Mitigated Child is not started if the first fetch fails test_cleanup_when_first_fetch_fails
UCA-11.6 Mitigated (opt in) With --export-token-env the wrapper stops the child when the exported token expires and exits 75; the host restarts it with a fresh token. Detached grandchildren keep the expired value test_env_export_is_opt_in, test_env_export_stops_child_when_exported_token_expires, stdio wrapper

Not mitigated, summary

UCA Hazard Loss scenario Matching non-goal or gap
UCA-1.2, 3.2 (partly) H-2 LS-1 Cluster operators and authorized ServiceAccount users remain trusted
UCA-5.1, 5.2, 6.1 H-7 LS-7 No user delegation
UCA-2.4, 8.6 (partly) H-3 LS-8 No revocation of issued tokens; time to denial up to the 300s token TTL
UCA-8.5 (partly) H-2 LS-9 JWT-SVID reuse allowed in the demo (SPIRE 1.15.3 agent SVID cache)
UCA-10.8 (partly) H-3 none listed Tools without their own expiry re-check can finish a side effect after exp

6. Demo scenarios from this analysis

Each item turns a loss scenario into a scripted, repeatable result, next to the four base scenarios in Demo scenarios. All run in the checked demo; scenario 10 is a deterministic authority test without an LLM. See Publication validation for execution status.

# Scenario Result Covers
5 A second pod copies the research label but uses a different ServiceAccount Token request denied; copied labels do not confer the research workload identity LS-1, UCA-3.2
6 Malicious PRM: notes-rogue names a rogue AS in authorization_servers Client logs issuer_refused and fetches no SVID LS-3, UCA-7.4
7 SVID replay: the same JWT-SVID posted twice to /token Two tokens under allow-reuse-within-lifetime, the mode the SPIRE 1.15.3 agent forces; invalid_client on the second under reject (live scenario 13 and offline tests) LS-9, UCA-8.5
8 Upstream call without passthrough: notes-a calls notes-b with its own token notes-b audits mcp/notes-a; the forwarding variant is scenario 4 (401) LS-4, UCA-10.5
9 Grant revocation: remove a grant during a run invalid_target after the next reload (0.9s and 5.0s in two runs, poll 5s); issued tokens live until exp LS-8, UCA-2.4
10 Prompt-injected write: a note tells the agent to call notes.write Scripted as a deterministic tool request: read-only grant receives 403, read-write grant succeeds. Identity does not determine intent LS-7, UCA-5.1
11 authz restart: new signing key while servers cache the old JWKS No 401: the unknown kid refetch picks up the new key at once, bounded by the 10s refetch rate limit LS-10, UCA-10.1