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:
/oauth/tokencurl -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:
/api/v1/userscurl -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:
- Request body (as above) — simple, works everywhere.
- Basic Auth header (
Authorization: Basic base64(client_id:client_secret)) — RFC-recommended. - JWT bearer (
client_assertionfor 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.*oradmin— 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:
- Get a token.
- Cache it with a TTL of
expires_in - 60seconds (60s safety buffer). - Use the cached token for all API calls.
- On 401 response, assume the token expired mid-flight, discard, get a new one, retry.
- 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→ 400invalid_request. - Wrong
grant_type(saypassword) → 400unsupported_grant_type(or similar). - Wrong
client_secret→ 401invalid_client. - Unknown
client_id→ 401invalid_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→ returnsactive: true/false.
Security
- Token in URL (shouldn't be accepted) — resource server should reject
?access_token=.... - Token from a different
iss— reject. - Token with
audmismatched — 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
JWTs pack auth and user context into a signed string. Simple on the surface, full of traps underneath.
Simple, common, and surprisingly easy to get wrong. Here's the API key playbook.
Short-lived access tokens need graceful refresh. Three common patterns, one big pitfall.