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 Attestation selectors (Docker container label here)
Authorization server Token issuance SPIRE JWT bundle, grant policy loaded at start
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 match processes other than the intended workload (any container that carries the registered label) [H-2]. UCA-1.3 Entry sets a long JWT-SVID TTL [H-3] UCA-1.4 Entry created before the label namespace or host is 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 Anyone who can start containers on the host starts one with the label org.example.svid.workload=agent-research. The docker attestor cannot tell it apart, so the SPIRE agent issues it the agent's SVID. It then gets tokens within the agent's full grant. A process that controls the SPIRE agent container also controls the Docker daemon through the mounted socket. 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 from the MCP server itself remains an SSRF vector. 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. 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 loads policy only at start, so tokens keep being issued until restart, and issued tokens stay valid for up to 5 minutes after that. 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 compose 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 docker attestor binds identity to a container label, not a UID. Anyone who can start containers on the host can set any label, and the SPIRE agent holds the Docker socket Demo: an unregistered or missing label gets PERMISSION_DENIED; deploy/spire/agent.conf. Known gaps "Label selectors" and "Docker socket in the SPIRE agent"
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 Not mitigated Policy loaded once at start; no token revocation or introspection Non-goal "Not production software"; bounded by the 5-minute token TTL
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. No HTTPS or private-range check on the PRM URL itself test_untrusted_issuer_refused_before_svid, test_path_issuer_trailing_slash_refused, test_agent_cli_requires_trusted_issuer, test_stdio_cli_requires_trusted_issuer
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 compose 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 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 by design, no test Server makes no outbound call with the incoming token (security invariant 3). There is no upstream call to test yet mcp_server.py, AGENTS.md. Non-goal "No token exchange or act claim chain yet"
UCA-10.6 Mitigated One audit line per decision; pre-verification identity prefixed unverified: test_authz_audit_lines, test_token_for_a_is_rejected_by_b
UCA-10.8 Not mitigated Token checked once per HTTP request Non-goal "Not production software"; tools here are short
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 Not mitigated (opt in) --export-token-env value is never refreshed or removed test_env_export_is_opt_in, stdio wrapper

Not mitigated, summary

UCA Hazard Loss scenario Matching non-goal or gap
UCA-1.2, 3.2 (partly) H-2 LS-1 Label selectors; Docker socket in the SPIRE agent
UCA-5.1, 5.2, 6.1 H-7 LS-7 No user delegation
UCA-2.4, 8.6 H-3 LS-8 Not production software (no revocation, no policy reload)
UCA-8.5 (partly) H-2 LS-9 JWT-SVID reuse allowed in the demo (SPIRE 1.15.3 agent SVID cache)
UCA-10.5 (no test) H-2 LS-4 No token exchange or act chain
UCA-10.8 H-3 none listed Not production software
UCA-11.6 H-2, H-3 LS-5 --export-token-env is opt in

6. Next demo scenarios

Each item turns a loss scenario into a scripted, repeatable result, like the four in Demo scenarios.

# Scenario Shows Covers
5 Label impostor: a second container with the agent-research label gets agent/research and a token The limit of label selectors; then the same run with an image digest selector or the k8s attestor denying it LS-1, UCA-3.2
6 Malicious PRM: notes-b names a rogue AS in authorization_servers Client logs issuer_refused and fetches no SVID (covered offline in test_issuer_allowlist.py) LS-3, UCA-7.4
7 SVID replay: the same JWT-SVID posted twice to /token invalid_client on the second under reject; two tokens and one svid_jti in the audit under allow-reuse-within-lifetime. Needs a SPIRE agent that honours -jwtSVIDIncludeJTI cache bypass to run under reject LS-9, UCA-8.5
8 Upstream call without passthrough: notes-a calls notes-b with its own SVID-based token, and a variant that forwards the caller token gets 401 at notes-b Invariant 3 as an executable test, and the missing act chain LS-4, UCA-10.5
9 Grant revocation: remove a grant during a run Time from policy change to denial (restart plus up to 300s) LS-8, UCA-2.4
10 Prompt-injected write: a note tells the agent to call notes.write All checks pass under a read-write grant and fail under notes:read only; identity limits the blast radius but not intent LS-7, UCA-5.1
11 authz restart: new signing key while servers cache the old JWKS At most 10s of 401s, bounded by the refetch rate limit LS-10, UCA-10.1