Backend service + MCP (no user)

At a glance. No human, no browser, no consent screen. A backend service authenticates with its own client_id + client_secret and gets a machine token. Standard OAuth 2.1 client_credentials grant (RFC 6749 §4.4). Same audience binding as the user flow — token is bound to the target MCP’s resource URI.

Topology

flowchart LR
    Backend["Backend service<br/>(worker, CI, bot)"]
    AS["AuthPlane"]
    MCP["MCP Server"]

    Backend -->|M2M auth| AS
    AS -->|JWKS + PRM| MCP
    Backend -->|"Bearer(aud=mcp)"| MCP

Two components on the AS side: backend service — a confidential OAuth client with client_credentials in its grant types. MCP server — the Mint Resource. No user. No consent grants stored.

Flow

sequenceDiagram
    participant Backend
    participant AS as AuthPlane
    participant MCP as MCP Server

    Backend->>AS: POST /oauth/token<br/>grant_type=client_credentials<br/>client_id=$BACKEND_CLIENT_ID<br/>client_secret=$BACKEND_SECRET<br/>scope=tools/query<br/>resource=https://mcp.example.com/mcp
    AS-->>Backend: { access_token, token_type=Bearer, expires_in=3600 }
    Backend->>MCP: POST /mcp Authorization: Bearer <token>
    MCP->>AS: GET /.well-known/jwks.json (cached)
    AS-->>MCP: JWKS
    MCP-->>Backend: response

No refresh token — machines re-authenticate every hour (or whatever client_credentials.token_expiry is).

Token claim shape: sub = the backend’s client_id (not a user), client_id = same, aud = target resource URI.

When to use

  • A CI/CD pipeline calling MCP tools to trigger builds, run analysis, deploy.
  • A backend worker processing a queue, calling MCP tools for summarization / DB queries / notifications.
  • An automated agent with no human in the loop.
  • A monitoring service calling health-check tools.

Don’t use when:

  • A user is in the loop and should authorize → single-mcp with authorization_code.
  • The service needs to act on behalf of a user (use the user’s GitHub token) → broker-mcp with token exchange, or the service-account-user pattern (see Concepts: Grants & flows).

How to configure

Enable the grant (disabled by default):

client_credentials:
  enabled: true
  token_expiry: 1h

Or AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true.

Register the resource (same as single-mcp):

authserver admin resource create \
    --slug my-mcp \
    --uri https://mcp.example.com/mcp \
    --backend-kind mint \
    --scopes 'tools/query||Query tools'

Register the backend as a confidential client:

authserver admin client create \
    --name backend-worker \
    --grant-types client_credentials \
    --auth-method client_secret_post \
    --scopes 'tools/query||Query tools'
# → save client_id and client_secret; secret is bcrypt-hashed after this call

Request a token from the backend:

curl -X POST http://localhost:9000/oauth/token \
    -d "grant_type=client_credentials" \
    -d "client_id=$CLIENT_ID" \
    -d "client_secret=$CLIENT_SECRET" \
    -d "scope=tools/query" \
    -d "resource=https://mcp.example.com/mcp"

How AuthPlane handles it

  • TokenService.ClientCredentials verifies client_id + client_secret (bcrypt compare against stored hash).
  • Scope resolution: if request includes scope, token gets intersection(requested, client.registered_scopes); if request omits scope, token gets all client’s registered scopes.
  • No consent_grants write — machines don’t consent.
  • MintIssuer signs the JWT with sub = client_id, writes an issuances audit row.
  • Optional DPoP binding: if the request has a DPoP header, token gets cnf.jkt.

Verify

# Confirm the issuance
authserver admin issuance list --client backend-worker --limit 3

# Decode the token — sub should equal client_id
echo "<access_token>" | cut -d. -f2 | base64 -d | jq '{sub, client_id, aud, scope}'

See also