intermediate·8 min read·Updated May 1, 2026

OAuth 2.0 Client Credentials: Machine-to-Machine Auth Done Right

OAuth 2.0's machine-to-machine flow. Clean, standard, and easy to test once you see the shape.

What client credentials is for

OAuth 2.0's client credentials grant is the flow you use when one application authenticates itself to another application — no user involved.

Examples:

  • A scheduled job syncing data from your CRM to your data warehouse.
  • A backend microservice calling another backend microservice.
  • A third-party integration reading data your company owns (not data belonging to a specific user).

For user-present auth (the user clicks "Sign in with Google"), you'd use authorization code flow with PKCE — a separate topic. Client credentials is purely server-to-server.

The flow in three steps

┌────────┐    1. POST /oauth/token (client_id + client_secret + scope)    ┌─────────────┐
│ Client │ ────────────────────────────────────────────────────────────> │ Auth Server │
│        │                                                               │             │
│        │ <──────────────── 2. { access_token, expires_in } ─────────── │             │
└────────┘                                                               └─────────────┘
    │
    │ 3. GET /api/resource (Authorization: Bearer <token>)
    ▼
┌──────────────┐
│ Resource API │
└──────────────┘

One extra round-trip compared to API keys — but you get:

  • Short-lived tokens (typically 1 hour).
  • Scopes (fine-grained permissions per token).
  • Standardized revocation and introspection.
  • Separation between who you are (client) and what you can do (scopes).

Try it

Get a token:

POST/oauth/token
Exchange client credentials for an access token.
curl -X POST 'https://demo.totalshiftleft.ai/oauth/token' \
  -H 'Content-Type: application/json' \
  -d '{"grant_type":"client_credentials","client_id":"demo-client","client_secret":"demo-secret","scope":"read:users"}'

Response:

{
  "access_token": "eyJhbGciOi...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read:users"
}

Now call the API with that token:

GET/api/v1/users
Call the API with the Authorization: Bearer <token> header.
curl -X GET 'https://demo.totalshiftleft.ai/api/v1/users'

Header: Authorization: Bearer <access_token>.

Request parameters

Every client credentials request has:

  • grant_type=client_credentials — tells the auth server which flow you want.
  • client_id — who you are.
  • client_secret — proof of who you are. Never put this in a browser-exposed client.
  • scope (optional) — what you want to do. Space-separated list (read:users write:orders).
  • audience (optional, vendor-specific) — which API this token is for. Auth0, for example, requires it.

Client ID and secret can be sent three ways:

  1. Request body (as above) — simple, works everywhere.
  2. Basic Auth header (Authorization: Basic base64(client_id:client_secret)) — RFC-recommended.
  3. JWT bearer (client_assertion for more secure clients) — advanced, uses asymmetric keys.

Scopes

Scopes are strings that name permissions. Conventions:

  • read:resource / write:resource — coarse verb-style.
  • resource.read / resource.write — dot-style, also common.
  • * or admin — full access. Avoid; grant minimal scopes per client.

A token requested with scope=read:users shouldn't be usable on write endpoints. The resource server is responsible for enforcing scopes; the auth server is responsible for issuing only the scopes a client is allowed.

What the access token actually looks like

Often a JWT — so everything in the JWT lesson applies. Claims typically include:

{
  "iss": "https://auth.example.com",
  "sub": "demo-client",
  "aud": "https://api.example.com",
  "scope": "read:users",
  "exp": 1712349278,
  "iat": 1712345678,
  "client_id": "demo-client"
}

No email or user-shaped claims — it's a client, not a user.

Some auth servers use opaque tokens instead of JWTs. The resource server then calls the /oauth/introspect endpoint to verify and get claims. Slower but supports easy revocation.

Token caching — the one thing everyone forgets

Tokens live for an hour. Getting a new one on every API call is:

  • Slow (extra round trip).
  • Rate-limit-burning (auth servers throttle /oauth/token).
  • Unnecessary.

Good clients:

  1. Get a token.
  2. Cache it with a TTL of expires_in - 60 seconds (60s safety buffer).
  3. Use the cached token for all API calls.
  4. On 401 response, assume the token expired mid-flight, discard, get a new one, retry.
  5. On startup, get a token proactively.

Bad clients hit /oauth/token before every call. The auth server will throttle you and your p99 latency will be unpredictable.

What to test

Happy paths

  • Valid client_id + client_secret + scope → 200 with token.
  • Token works on intended endpoints with the granted scope.

Negatives

  • Missing grant_type → 400 invalid_request.
  • Wrong grant_type (say password) → 400 unsupported_grant_type (or similar).
  • Wrong client_secret → 401 invalid_client.
  • Unknown client_id → 401 invalid_client.
  • Requesting a scope the client isn't authorized for → 400 invalid_scope (not 200 with fewer scopes — server behavior varies, document it).
  • Expired token on a resource call → 401 with WWW-Authenticate: Bearer error="invalid_token".
  • Token with insufficient scope → 403 with WWW-Authenticate: Bearer error="insufficient_scope".

Edge cases

  • Huge scope list (50 scopes) → should work or 400.
  • Re-requesting a token within the same second — most servers issue a new one; some return the same one (caching at auth server).
  • Revoking a token via /oauth/revoke → subsequent calls return 401.
  • Token introspection via /oauth/introspect → returns active: true/false.

Security

  • Token in URL (shouldn't be accepted) — resource server should reject ?access_token=....
  • Token from a different iss — reject.
  • Token with aud mismatched — reject.
  • Clock-skew window on exp — should be ≤5 minutes.

Common mistakes

1. Shipping client_secret in frontend code. Public clients (browsers, mobile apps) can't keep secrets. Use authorization code flow with PKCE instead.

2. Ignoring expires_in. Client gets a token, uses it forever. When it expires, cascade of 401s in production.

3. Logging the client_secret. Scrub auth request bodies in your log pipeline.

4. Matching scopes with string equality. read:users in the token vs read:user required by the endpoint — off-by-one-character and you let in unauthorized callers. Use an exact canonical list.

5. Storing client_secret in git. Vault, AWS Secrets Manager, doppler — anything but the repo.

What's next

Tokens expire. Clients need to refresh gracefully. Token refresh patterns covers the two standard approaches and how to test them.

Related lessons

Read more on the blog