OAuth2 Client Credentials Flow: Machine-to-Machine Authentication
How do I implement OAuth2 Client Credentials flow?
TL;DR
- Bottom line: The Client Credentials grant (RFC 6749 Section 4.4) lets a server-side application exchange its
client_id+client_secretfor an access token — no user interaction required. It is the standard pattern for machine-to-machine (M2M) authentication. - Key tool/command:
POST /oauth/tokenwithgrant_type=client_credentials&client_id=ID&client_secret=SECRET&scope=SCOPE - Watch out for: Not caching tokens — every uncached token request adds latency and can trigger rate limits; cache tokens in memory until
expires_in - buffer. - Works with: Any OAuth 2.0 compliant authorization server (Auth0, Okta, Microsoft Entra ID, AWS Cognito, Keycloak, Google Cloud IAM).
Constraints
- Confidential clients only — this grant MUST NOT be used by public clients (SPAs, mobile apps, desktop apps). The client must be able to securely store its secret. [src1]
- Never expose client_secret — do not commit to version control, embed in client-side code, or log. Use environment variables or a secrets manager. [src6]
- No refresh tokens — the spec explicitly states refresh tokens SHOULD NOT be issued for this grant. Re-authenticate with credentials to get a new token. [src1]
- TLS required — the token endpoint MUST be accessed over HTTPS. Credentials sent over HTTP can be intercepted. [src1]
- Minimum scope — always request only the scopes your service actually needs. Over-scoped tokens increase blast radius on compromise. [src6]
Quick Reference
Flow Steps
| Step | Action | Details |
|---|---|---|
| 1 | Register client | Create M2M application in your IdP; obtain client_id and client_secret |
| 2 | Configure scopes | Define API permissions — server-side, no user consent needed |
| 3 | Request token | POST /oauth/token with grant_type=client_credentials + credentials + scope |
| 4 | Auth method | HTTP Basic Authorization: Basic base64(id:secret) or form params |
| 5 | Receive token | { "access_token": "...", "token_type": "Bearer", "expires_in": 3600 } |
| 6 | Cache token | Store in memory until expires_in - 60s buffer |
| 7 | Use token | Authorization: Bearer {access_token} |
| 8 | Handle expiry | Re-request on 401; fetch new token, retry original request |
| 9 | Rotate credentials | Use two active secrets for zero-downtime rotation |
| 10 | Monitor | Track issuance rate, failures, scope usage |
Token Request Parameters
| Parameter | Required | Value |
|---|---|---|
grant_type | Yes | client_credentials |
client_id | Yes | Your application's client ID |
client_secret | Yes | Your application's client secret |
scope | Recommended | Space-separated list of requested scopes |
audience | Provider-specific | API identifier (Auth0, Okta) |
resource | Provider-specific | Resource URI (Microsoft Entra ID) |
Common Provider Token Endpoints
| Provider | Token Endpoint |
|---|---|
| Auth0 | https://{tenant}.auth0.com/oauth/token |
| Okta | https://{domain}/oauth2/default/v1/token |
| Microsoft Entra ID | https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token |
| AWS Cognito | https://{domain}.auth.{region}.amazoncognito.com/oauth2/token |
| Google Cloud | https://oauth2.googleapis.com/token |
| Keycloak | https://{host}/realms/{realm}/protocol/openid-connect/token |
Decision Tree
START: Does your service need to call a protected API?
├── Is there a user context (acting on behalf of a user)?
│ ├── YES → Use Authorization Code flow with PKCE (not Client Credentials)
│ └── NO ↓
├── Is the client a server-side application that can securely store secrets?
│ ├── NO → Cannot use Client Credentials. Use API keys or mTLS instead.
│ └── YES ↓
├── Does the provider support certificate-based auth?
│ ├── YES and security policy requires it → Use client_assertion (JWT signed with cert)
│ └── NO or shared secret is acceptable ↓
├── Does the provider require an audience/resource parameter?
│ ├── Auth0/Okta → Include `audience` parameter
│ ├── Microsoft Entra ID → Include `scope` with `.default` suffix
│ └── Standard → Use `scope` parameter only
└── IMPLEMENT → POST to token endpoint, cache token, add Bearer header to API calls
Step-by-Step Guide
1. Register a Machine-to-Machine application
Create an M2M (non-interactive) application in your identity provider. Record the client_id and client_secret. Store the secret in your secrets manager immediately. [src2]
# Example: Auth0 — create via Management API
curl -X POST "https://{tenant}.auth0.com/api/v2/clients" \
-H "Authorization: Bearer {mgmt_token}" \
-H "Content-Type: application/json" \
-d '{
"name": "My Backend Service",
"app_type": "non_interactive",
"grant_types": ["client_credentials"]
}'
Verify: Check your IdP dashboard — the new application should appear with client_credentials as the only allowed grant type.
2. Configure API permissions (scopes)
Authorize your application to access specific APIs with specific scopes. This is provider-specific but typically done in the IdP admin console. [src2]
# Example: Auth0 — grant API access to the client
curl -X POST "https://{tenant}.auth0.com/api/v2/client-grants" \
-H "Authorization: Bearer {mgmt_token}" \
-H "Content-Type: application/json" \
-d '{
"client_id": "{your_client_id}",
"audience": "https://api.example.com",
"scope": ["read:data", "write:data"]
}'
Verify: In IdP console, confirm the application has the expected API grants and scopes listed.
3. Request an access token
Send a POST request to the token endpoint with grant_type=client_credentials. Credentials can be sent as HTTP Basic auth or as POST body parameters. [src1]
# Method A: Credentials in POST body (most common)
curl -X POST "https://{tenant}.auth0.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "audience=https://api.example.com"
# Method B: HTTP Basic Authentication (RFC 6749 recommended)
curl -X POST "https://{tenant}.auth0.com/oauth/token" \
-u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "audience=https://api.example.com"
Verify: Response is 200 OK with JSON containing access_token, token_type, and expires_in.
4. Cache the token and use it
Cache the token in memory with an expiry buffer. Use it in the Authorization header for all API calls. On 401 Unauthorized, refresh the token and retry. [src5]
import time, threading
class TokenCache:
"""Thread-safe in-memory token cache with expiry buffer."""
def __init__(self, fetch_fn, buffer_seconds=60):
self._fetch = fetch_fn
self._buffer = buffer_seconds
self._token = None
self._expires_at = 0
self._lock = threading.Lock()
def get_token(self):
if time.time() < self._expires_at:
return self._token
with self._lock:
if time.time() < self._expires_at:
return self._token
data = self._fetch()
self._token = data["access_token"]
self._expires_at = time.time() + data["expires_in"] - self._buffer
return self._token
Verify: Call get_token() twice in succession — the second call should return instantly without an HTTP request.
5. Implement credential rotation
Set up two active client secrets with overlapping validity. Rotate the older secret periodically without downtime. [src6]
# 1. Generate a new secret (provider-specific)
# 2. Deploy the new secret to your service
# 3. Verify the new secret works (test token request)
# 4. Revoke the old secret after deployment is confirmed
Verify: Confirm token requests succeed with the new secret. Monitor for failures during rotation window.
Code Examples
Python: Client Credentials with httpx
# Input: client_id, client_secret, token_url, audience
# Output: Bearer access token for API calls
import httpx # httpx >= 0.27.0
def get_client_credentials_token(
token_url: str,
client_id: str,
client_secret: str,
scope: str = "",
audience: str = "",
) -> dict:
"""Request token via OAuth2 Client Credentials grant."""
payload = {
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
}
if scope:
payload["scope"] = scope
if audience:
payload["audience"] = audience
resp = httpx.post(token_url, data=payload)
resp.raise_for_status()
return resp.json()
Node.js: Client Credentials with fetch
// Input: tokenUrl, clientId, clientSecret, scope
// Output: { access_token, token_type, expires_in }
async function getClientCredentialsToken(tokenUrl, clientId, clientSecret, scope = '') {
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
});
if (scope) params.set('scope', scope);
const res = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!res.ok) throw new Error(`Token request failed: ${res.status} ${await res.text()}`);
return res.json();
}
Go: Client Credentials with golang.org/x/oauth2
// Input: clientID, clientSecret, tokenURL, scopes
// Output: *oauth2.Token with AccessToken field
package main
import (
"context"
"fmt"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
)
func getToken(clientID, clientSecret, tokenURL string, scopes []string) (*oauth2.Token, error) {
cfg := &clientcredentials.Config{
ClientID: clientID,
ClientSecret: clientSecret,
TokenURL: tokenURL,
Scopes: scopes,
}
token, err := cfg.Token(context.Background())
if err != nil {
return nil, fmt.Errorf("token request failed: %w", err)
}
return token, nil
}
cURL: Direct token request
# Input: TOKEN_URL, CLIENT_ID, CLIENT_SECRET, SCOPE
# Output: JSON with access_token, token_type, expires_in
curl -s -X POST "$TOKEN_URL" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=$SCOPE" | jq .
Anti-Patterns
Wrong: Hardcoding credentials in source code
# BAD — credentials in source code get committed to git
client_id = "abc123"
client_secret = "super_secret_key_2026"
token = get_token(client_id, client_secret)
Correct: Load credentials from environment or secrets manager
# GOOD — credentials loaded from environment variables
import os
client_id = os.environ["OAUTH_CLIENT_ID"]
client_secret = os.environ["OAUTH_CLIENT_SECRET"]
token = get_token(client_id, client_secret)
Wrong: Requesting a new token on every API call
// BAD — token request on every call adds ~200ms latency + risks rate limiting
async function callApi(endpoint) {
const { access_token } = await getToken(); // Network call every time!
return fetch(endpoint, { headers: { Authorization: `Bearer ${access_token}` } });
}
Correct: Cache tokens and reuse until near-expiry
// GOOD — cache token, refresh only when expired (with 60s buffer)
let cached = { token: null, expiresAt: 0 };
async function callApi(endpoint) {
if (Date.now() / 1000 >= cached.expiresAt) {
const data = await getToken();
cached = { token: data.access_token, expiresAt: Date.now() / 1000 + data.expires_in - 60 };
}
return fetch(endpoint, { headers: { Authorization: `Bearer ${cached.token}` } });
}
Wrong: Requesting all available scopes
# BAD — over-scoped token: if compromised, attacker has full access
curl -X POST "$TOKEN_URL" -d "grant_type=client_credentials" \
-d "client_id=$ID" -d "client_secret=$SECRET" \
-d "scope=read:all write:all admin:all delete:all"
Correct: Request minimum required scopes
# GOOD — only request what this specific service needs
curl -X POST "$TOKEN_URL" -d "grant_type=client_credentials" \
-d "client_id=$ID" -d "client_secret=$SECRET" \
-d "scope=read:orders write:shipments"
Wrong: Using Client Credentials from a browser
// BAD — client_secret exposed in browser JS, visible in DevTools
const token = await fetch('/oauth/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: 'my_client_id',
client_secret: 'EXPOSED_SECRET', // Anyone can see this!
}),
});
Correct: Use Authorization Code with PKCE for browser apps
// GOOD — PKCE flow for browser: no client_secret needed
window.location.href = `${authUrl}/authorize?` +
`response_type=code&client_id=${clientId}&` +
`redirect_uri=${redirectUri}&` +
`code_challenge=${codeChallenge}&code_challenge_method=S256`;
Common Pitfalls
- Token not cached, service hits rate limits: Most providers rate-limit the token endpoint (e.g., Auth0: 1000 req/min). Fix: Cache tokens in memory with
expires_in - 60sbuffer. [src5] - Using HTTP instead of HTTPS: Credentials sent in cleartext. Fix: Always use
https://URLs; configure TLS verification in your HTTP client. [src1] - Wrong Content-Type header: Token endpoint expects
application/x-www-form-urlencoded, notapplication/json. Fix: Set correct Content-Type and URL-encode the body. [src1] - Forgetting audience/resource parameter: Auth0 requires
audience, Microsoft Entra ID requiresscopewith.defaultsuffix. Fix: Check your provider's docs. [src2] [src4] - Not URL-encoding the client_secret: Secrets containing
+,&,=, or%break form encoding. Fix: URL-encode credentials or use HTTP Basic auth. [src4] - Treating token_type as case-sensitive: RFC 6749 specifies
token_typeis case-insensitive. Fix: Comparetoken_type.toLowerCase() === "bearer". [src1] - No retry logic on token failure: Network blips cause hard failures. Fix: Implement exponential backoff (3 retries, 1s/2s/4s). [src6]
- Single secret with no rotation: Rotation causes downtime. Fix: Maintain two active secrets; deploy new, then revoke old. [src6]
Diagnostic Commands
# Test token request (replace variables)
curl -v -X POST "$TOKEN_URL" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&scope=$SCOPE"
# Decode a JWT access token (inspect claims without verification)
echo "$ACCESS_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
# Check token expiry from JWT claims
echo "$ACCESS_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{exp: .exp, exp_human: (.exp | todate), iss: .iss, aud: .aud, scope: .scope}'
# Verify token against an API endpoint
curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ACCESS_TOKEN" "$API_URL/health"
# Check provider token endpoint connectivity
curl -s -o /dev/null -w "HTTP %{http_code} in %{time_total}s\n" -X POST "$TOKEN_URL" -d ""
# Monitor token request latency
time curl -s -X POST "$TOKEN_URL" \
-d "grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET" > /dev/null
Version History & Compatibility
| Spec/Version | Status | Key Changes | Notes |
|---|---|---|---|
| RFC 6749 (2012) | Current Standard | Defined Client Credentials grant in Section 4.4 | Foundation spec; all providers implement this |
| OAuth 2.1 (draft) | Draft (expected 2025-2026) | Preserves client_credentials unchanged | Removes implicit + ROPC grants |
| RFC 7523 (2015) | Current | JWT Profile for client authentication | Alternative to shared secrets; uses signed JWT |
| RFC 8705 (2020) | Current | Mutual TLS sender-constrained tokens | Strongest auth method for client credentials |
| BCP (draft-29, 2024) | Draft BCP | Recommends asymmetric client auth | Guidance, not a breaking change |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Service-to-service / M2M communication | User needs to grant consent | Authorization Code + PKCE |
| Backend cron jobs calling APIs | Client is a browser SPA | Authorization Code + PKCE |
| Microservice-to-microservice auth | Client is a mobile/desktop app | Authorization Code + PKCE |
| CI/CD pipeline API access | IoT device with no secure storage | Device Authorization Grant |
| Daemon processes and workers | Simple internal API with no OAuth infra | API keys or mTLS |
| Automated data sync between services | Need user-specific data/permissions | Authorization Code + PKCE |
Important Caveats
- No user context: Tokens issued via Client Credentials have no
sub(subject) claim representing a user. API authorization logic must account for app-level vs. user-level tokens differently. - Provider-specific parameters: Auth0 requires
audience, Microsoft Entra ID usesscopewith.defaultsuffix, Okta usesscopedirectly. Always check your provider's documentation. - Certificate auth preferred for high security: RFC 7523 (JWT assertion) and RFC 8705 (mTLS) are recommended over shared secrets by the IETF Security BCP. Shared secrets are acceptable but have higher credential leakage risk.
- Rate limiting: Token endpoints are typically rate-limited. Auth0 allows ~1000 requests/minute per tenant. Always cache tokens.
- Opaque vs. JWT tokens: Some providers issue opaque tokens, others issue JWTs. Do not parse or validate tokens for APIs you do not own.