Machine-to-machine auth: three methods
When you need this
A data 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. (If the caller acts on behalf of a logged-in user, that's guide 3.)
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".
In the SDK they're three interchangeable credential classes — same
AgentIdentity, same access_for():
from authsec_sdk import AgentIdentity, ClientSecretAuth, PrivateKeyJwtAuth, SpiffeSvidAuth
agent = AgentIdentity(ISSUER, CLIENT_ID, auth=ClientSecretAuth("sec_...")) # A
agent = AgentIdentity(ISSUER, CLIENT_ID, auth=PrivateKeyJwtAuth("key.pem", kid="key-1")) # B
agent = AgentIdentity(ISSUER, CLIENT_ID, auth=SpiffeSvidAuth(svid)) # C
async with agent:
token = await agent.access_for(MCP_URL, requested_scopes=["test_mcp:read"])
Pick your method
| Method | Guide |
|---|---|
| A. Client secret — simplest, shared secret | Client secret |
| B. Private-key JWT — RFC 7523, key never leaves your machine | Private-key JWT |
| C. SPIFFE SVID — Kubernetes, zero stored credentials | Kubernetes / SPIFFE |
Service accounts — the dashboard side
Machines are registered as Service Accounts. Open the page from the sidebar — as its description says, each service account is a machine principal holding one credential (client secret, private-key JWT, or Kubernetes SPIFFE SVID) and is "granted access to specific MCP servers independently of any user session". Click + Create service account:

The filters show the credential split at a glance (Credential (M2M) / Kubernetes / No credential), and the Auth method column shows what each account holds.
Methods A and B are created here (pick the auth method, then grant access). Method C (SPIFFE) takes a different path — as its option card says, it's "configured per MCP server": you register a trust domain once under Trusted Issuers, then connect the workload from the app's Access tab. Full walkthrough in Method C below.
Grant access — required for every method
Creating a service account gives it an identity, not permissions. The credentials dialog says it directly: "Grant this service account access to an MCP server from its Access Assignments tab." Until you do, every token request fails with:
access_denied: client not authorized for this resource server
Assign a role (e.g. Readonly with the read scopes) to the service account
for your target application — the same roles you built in
guide 1, step 7.
Once granted, the connection appears on the application's Connections
tab as an active (m2m) connection showing the role and scopes it was
granted through.
Verify
Run the code from any method — then prove the token works:
import httpx, json
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. The application's M2M Logs (sidebar, under Monitor) shows every token grant as it happens.
Troubleshooting
Every row here is an error we hit for real while building this SDK:
| Error | Cause | Fix |
|---|---|---|
invalid_client: invalid client secret | Typo'd/rotated secret (they're 64 hex chars) | Copy-paste from the dashboard, never retype |
access_denied: client not authorized for this resource server | Credential is valid but no access assignment exists | Grant a role for the target application (section above) |
JWKS resolution failed: parse JWKS: invalid character '<' | JWKS URI returns HTML (gist page URL, 404 page, …) | Point it at raw JSON; for gists use the raw 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, or wrong key | Make PrivateKeyJwtAuth(kid=...) match the JWKS kid |