Client Credentials for Machine-to-Machine

SecurityAuthenticationAPIBackend
Share on LinkedIn

Your billing service calls the inventory API with a shared API key in an environment variable. When the key rotates, someone manually updates twelve deployment configs and hopes nothing was missed. OAuth 2.0 client credentials flow gives machine-to-machine authentication with scoped tokens, expiration, and centralized revocation—without a human user in the loop. The service authenticates as itself using a client_id and client_secret, receives a short-lived access token, and presents it on API calls.

Flow

Service A                    Auth Server                Service B (API)
   |--- client_id + secret -->|                              |
   |<-- access_token ---------|                              |
   |--- GET /resources ------>|                              |
   |   Authorization: Bearer  |                              |
   |                          |<--- validate token ----------|
   |<-- response -------------|                              |

No browser, no redirect, no user consent screen.

Token request

curl -X POST https://auth.example.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=billing-service" \
  -d "client_secret=s3cret" \
  -d "scope=inventory:read orders:write"

Response:

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "inventory:read orders:write"
}

Client implementation with caching

import time
import httpx

class M2MClient:
    def __init__(self, token_url, client_id, client_secret, scopes):
        self.token_url = token_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.scopes = scopes
        self._token = None
        self._expires_at = 0

    def get_token(self) -> str:
        if self._token and time.time() < self._expires_at - 60:
            return self._token

        resp = httpx.post(self.token_url, data={
            "grant_type": "client_credentials",
            "client_id": self.client_id,
            "client_secret": self.client_secret,
            "scope": self.scopes,
        })
        resp.raise_for_status()
        data = resp.json()
        self._token = data["access_token"]
        self._expires_at = time.time() + data["expires_in"]
        return self._token

    def request(self, method, url, **kwargs):
        headers = kwargs.pop("headers", {})
        headers["Authorization"] = f"Bearer {self.get_token()}"
        return httpx.request(method, url, headers=headers, **kwargs)

Cache tokens until 60 seconds before expiry. Requesting a new token per API call wastes latency and loads the auth server.

Scope design

billing-service    → inventory:read, orders:write
analytics-worker   → events:read
admin-tool         → * (avoid in production)

Principle of least privilege: each service gets only the scopes it needs. Audit scope grants like firewall rules.

JWT access tokens

Many providers return JWTs as access tokens:

{
  "iss": "https://auth.example.com",
  "sub": "billing-service",
  "aud": "api.example.com",
  "scope": "inventory:read orders:write",
  "exp": 1726500000,
  "iat": 1726496400
}

API resource servers validate locally:

from jose import jwt, JWTError

def validate_token(token: str) -> dict:
    try:
        return jwt.decode(
            token,
            PUBLIC_KEY,
            algorithms=["RS256"],
            audience="api.example.com",
            issuer="https://auth.example.com",
        )
    except JWTError:
        raise Unauthorized()

No call to the auth server per request—just signature verification.

Credential storage

Environment Storage
Kubernetes Secret mounted as env var or file
AWS Secrets Manager, referenced in task definition
CI/CD GitHub Actions secrets, OIDC for keyless
Local dev .env (never committed)

Never hardcode client_secret in source code. Rotate quarterly.

Client credentials vs alternatives

Method Scoped Expiring Revocable Complexity
API key No No Yes (delete key) Low
Client credentials Yes Yes Yes Medium
mTLS Yes (cert CN) Yes (cert expiry) Yes (revoke cert) High
JWT (self-signed) Yes Yes Hard Medium

Use client credentials when you already run an OAuth authorization server (Auth0, Keycloak, Okta, Cognito). Use mTLS when transport-layer identity is required. Use API keys for internal tools with one permission level.

Auth0 / Okta example

import fetch from "node-fetch";

async function getManagementToken() {
  const res = await fetch(`https://${DOMAIN}/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      audience: `https://${DOMAIN}/api/v2/`,
      grant_type: "client_credentials",
    }),
  });
  const { access_token } = await res.json();
  return access_token;
}

Token caching and refresh

M2M tokens typically expire in 3600 seconds. Cache aggressively:

let cachedToken = null;
let expiresAt = 0;

async function getToken() {
  if (Date.now() < expiresAt - 60_000) return cachedToken; // 60s skew buffer
  const res = await fetchToken();
  cachedToken = res.access_token;
  expiresAt = Date.now() + res.expires_in * 1000;
  return cachedToken;
}

Share cache per process, not per request — hammering /oauth/token triggers rate limits and marks your service as misconfigured in IdP dashboards.

Scope design for service accounts

One client credential per service, scopes per capability:

billing-service    → billing:read billing:write
reporting-worker   → analytics:read (read-only)
admin-backfill     → admin:write (break-glass, short-lived)

Avoid scope: * on M2M clients. Rotate credentials by creating new client, deploying, revoking old — blue-green for secrets.

Observability and audit

Log token requests without secrets:

{"event":"token_issued","client_id":"billing-service","scopes":["billing:write"],"duration_ms":45}

Alert on:

Pair with API authentication JWT vs sessions when choosing token format for M2M vs user contexts.

Production checklist

Common production mistakes

Teams get client credentials m2m wrong in predictable ways:

OAuth flows involving client credentials m2m leak sessions when refresh tokens are stored in localStorage, redirect URI validation is loose in staging, and token introspection is skipped for opaque bearer tokens.

Resources

Frequently asked questions

When should I use client credentials instead of API keys?

Use client credentials when you need scoped access (read-only vs write), token expiration with automatic renewal, audit trails tied to client identity, or integration with an existing OAuth infrastructure. API keys are simpler for single-purpose internal tools with one permission level.

Can client credentials flow access user data?

No. Client credentials grants access on behalf of the application itself, not a user. The token represents the service account. To act on behalf of a user, use authorization code flow or token exchange (RFC 8693).

How do I rotate client secrets without downtime?

Issue a new client secret while the old one remains valid for an overlap period (7–14 days). Deploy the new secret to all consuming services, verify traffic succeeds, then revoke the old secret. Automate rotation quarterly.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →