Machine-to-machine auth (Python SDK)
A pipeline, a cron job, a backend service, a Kubernetes workload — any program that calls a protected MCP server as itself, with its own standing permissions, no user session.
For the step-by-step dashboard flow with screenshots (create the service account, grant a role, verify the connection), see Connect agents and services.
The three credential classes
All three methods end at the same place — POST /oauth/token
(client_credentials) → scoped access token. They differ only in how
the machine proves its identity:
| Method | Proof | Secret on the wire? | Best for |
|---|---|---|---|
| A. Client secret | ID + shared secret (HTTP Basic) | ⚠️ every request | quick starts, simple deployments |
| B. Private-key JWT | RS256-signed assertion (RFC 7523) | never — key stays local | enterprise security postures |
| C. SPIFFE SVID | platform-attested workload identity | no stored credential at all | Kubernetes |
Security ladder: A → B → C goes from "shared password" to "asymmetric keys" to "the infrastructure itself vouches for the workload".
Unified API — AgentIdentity
In the SDK all three are interchangeable credential classes. The caller
code is identical; only the auth= parameter changes:
from authsec_sdk import AgentIdentity, ClientSecretAuth, PrivateKeyJwtAuth, SpiffeSvidAuth
# Method A — shared secret
agent = AgentIdentity(ISSUER, CLIENT_ID, auth=ClientSecretAuth("sec_..."))
# Method B — asymmetric key (key never leaves your machine)
agent = AgentIdentity(ISSUER, CLIENT_ID, auth=PrivateKeyJwtAuth("key.pem", kid="key-1"))
# Method C — platform attestation (zero stored credentials)
agent = AgentIdentity(ISSUER, CLIENT_ID, auth=SpiffeSvidAuth(svid))
# All three use the same token-acquisition call:
async with agent:
token = await agent.access_for(MCP_URL, requested_scopes=["my_mcp:read"])
Per-method guides
| Method | SDK guide | Dashboard guide |
|---|---|---|
| A. Client secret | Python code | Dashboard steps |
| B. Private-key JWT | Python code | Dashboard steps |
| C. SPIFFE SVID | Python code | Dashboard steps |
SDK class reference
AgentIdentity
The core class for all M2M and ID-JAG flows. Manages token acquisition, caching, and refresh.
from authsec_sdk import AgentIdentity
agent = AgentIdentity(
issuer, # str — the OAuth issuer URL
client_id, # str — the service account or agent ID
auth=..., # ClientSecretAuth | PrivateKeyJwtAuth | SpiffeSvidAuth
client_secret=..., # str (optional) — used for ID-JAG agents instead of auth=
idp_issuer=..., # str (optional) — the IdP issuer for ID-JAG token exchange
)
| Method | What it does |
|---|---|
await agent.access_for(mcp_url, requested_scopes=[...]) | Acquire a scoped access token for the target MCP server. Returns the token string. Caches until near expiry. |
await agent.access_for(mcp_url, user_session={...}, requested_scopes=[...]) | ID-JAG variant -- exchanges a user's id_token for a delegated token. |
agent.clear_cache(mcp_url) | Drop the cached token for this server. Use after a ConnectionRevokedError. |
async with agent: | Context manager -- opens and closes the internal HTTP session. |
ClientSecretAuth
from authsec_sdk import ClientSecretAuth
auth = ClientSecretAuth(client_secret) # str — the 64-char hex secret
Sends the client ID and secret as HTTP Basic credentials on each
client_credentials grant. Simplest method; secret crosses the wire on
every request.
PrivateKeyJwtAuth
from authsec_sdk import PrivateKeyJwtAuth
auth = PrivateKeyJwtAuth(
private_key, # str — path to PEM file, or the PEM string itself
kid="key-1", # str — must match the kid in your hosted JWKS
)
Signs a fresh JWT assertion per token request (5-min lifetime, unique
jti, audience = token endpoint). No secret crosses the wire -- AuthSec
verifies the signature against your public JWKS.
SpiffeSvidAuth
from authsec_sdk import SpiffeSvidAuth
auth = SpiffeSvidAuth(svid) # str — a JWT-SVID from SPIRE
Low-level class for manual SVID usage. Does not auto-renew -- use
SpiffeWorkloadIdentity in production (see below).
SpiffeWorkloadIdentity
from authsec_sdk import SpiffeWorkloadIdentity, SpiffeConfig
spiffe = SpiffeWorkloadIdentity(SpiffeConfig(
mcp_server_url="https://...",
client_id="...",
spiffe_id="spiffe://...",
scopes=["my_mcp:read"],
agent_socket_path="/run/spire/sockets/agent.sock", # default
))
High-level class that fetches SVIDs from the SPIRE agent socket and renews them automatically. Use this inside a Kubernetes pod.
| Method | What it does |
|---|---|
await spiffe.access_for() | Mint (or reuse) an SVID, exchange it for an access token. |
async with spiffe: | Context manager for the socket connection. |
ManifestTool
Used in tool_inventory_provider to declare tools for the manifest:
from authsec_sdk import ManifestTool
ManifestTool(
name="add_no", # str — the tool name (must match @mcp.tool)
description="Add numbers", # str — shown in the dashboard
input_schema={...}, # dict — JSON Schema for the tool's parameters
annotations=None, # dict (optional) — MCP annotations (readOnlyHint, etc.)
suggested_scopes=None, # list[str] (optional) — scope hints for the dashboard
)
ID-JAG helper functions
from authsec_sdk import browser_login, poll_until_approved
| Function | What it does |
|---|---|
await browser_login(issuer, client_id, resource) | Opens the browser for user login + consent. Returns an id_token. Runs a local HTTP server on localhost:8126/callback for the redirect. |
await poll_until_approved(agent, mcp_url, status_url, user_session, requested_scopes) | Polls AuthSec until the admin approves the agent's first connection. Returns the access token. |
ID-JAG exceptions
| Exception | When it's raised |
|---|---|
PendingApprovalError | Agent's first connection -- admin approval pending. Has a .status_url for polling. |
ApprovalDeniedError | Admin declined the connection request. |
ConnectionRevokedError | A previously approved connection was revoked. |
TrustedIssuerMissingError | The IdP isn't configured as a trusted issuer in AuthSec. |
CredentialInvalidError | Agent's client_id or secret is wrong. |
Grant access — required for every method
Creating a service account gives it an identity, not permissions. Until you grant it a role on the target application, every token request fails with:
access_denied: client not authorized for this resource server
Grant a role from the application's Access tab → Add access → Machine credential (secret/key). Full walkthrough: Connect — client secret.
Verify
Run the code from any method, then prove the token works:
import httpx
async def tools_list(token: str):
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(MCP_URL,
json={"jsonrpc": "2.0", "method": "tools/list", "id": 1},
headers={"Authorization": f"Bearer {token}",
"Accept": "application/json, text/event-stream"})
return r
You should see only the tools your granted scopes allow.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
invalid_client: invalid client secret | Typo'd/rotated secret (64 hex chars) | Copy-paste from the dashboard, never retype |
access_denied: client not authorized for this resource server | Credential valid, but no role granted | Grant a role on the target application |
JWKS resolution failed: parse JWKS: invalid character '<' | JWKS URI returns HTML (gist page URL, 404) | Use the raw JSON URL |
invalid_client: token aud must include this token endpoint | SPIFFE SVID minted with wrong audience | Mint with audience = <issuer>/oauth/token |
invalid_client: … token is expired | JWT-SVIDs live ~5 min | Mint right before use, or use SpiffeWorkloadIdentity |
| Signature verification fails (private-key JWT) | kid mismatch between code and JWKS | Make PrivateKeyJwtAuth(kid=...) match the JWKS kid |