Skip to main content

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:

MethodProofSecret on the wire?Best for
A. Client secretID + shared secret (HTTP Basic)⚠️ every requestquick starts, simple deployments
B. Private-key JWTRS256-signed assertion (RFC 7523)✅ never — key stays localenterprise security postures
C. SPIFFE SVIDplatform-attested workload identity✅ no stored credential at allKubernetes

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

MethodGuide
A. Client secret — simplest, shared secretClient secret
B. Private-key JWT — RFC 7523, key never leaves your machinePrivate-key JWT
C. SPIFFE SVID — Kubernetes, zero stored credentialsKubernetes / 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:

Service Accounts page

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:

ErrorCauseFix
invalid_client: invalid client secretTypo'd/rotated secret (they're 64 hex chars)Copy-paste from the dashboard, never retype
access_denied: client not authorized for this resource serverCredential is valid but no access assignment existsGrant 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 endpointSPIFFE SVID minted with wrong audienceMint with audience = <issuer>/oauth/token
invalid_client: … token is expiredJWT-SVIDs live ~5 minMint right before use, or use SpiffeWorkloadIdentity
Signature verification fails (private-key JWT)kid mismatch between code and JWKS, or wrong keyMake PrivateKeyJwtAuth(kid=...) match the JWKS kid