client_id + client_secret for an access token — no user interaction required. It is the standard pattern for machine-to-machine (M2M) authentication.POST /oauth/token with grant_type=client_credentials&client_id=ID&client_secret=SECRET&scope=SCOPEexpires_in - buffer.| 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 |
| 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) |
| 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 |
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
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.
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.
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.
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.
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.
# 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()
// 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();
}
// 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
}
# 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 .
# 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)
# 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)
// 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}` } });
}
// 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}` } });
}
# 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"
# 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"
// 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!
}),
});
// 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`;
expires_in - 60s buffer. [src5]https:// URLs; configure TLS verification in your HTTP client. [src1]application/x-www-form-urlencoded, not application/json. Fix: Set correct Content-Type and URL-encode the body. [src1]audience, Microsoft Entra ID requires scope with .default suffix. Fix: Check your provider's docs. [src2] [src4]+, &, =, or % break form encoding. Fix: URL-encode credentials or use HTTP Basic auth. [src4]token_type is case-insensitive. Fix: Compare token_type.toLowerCase() === "bearer". [src1]# 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
| 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 |
| 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 |
sub (subject) claim representing a user. API authorization logic must account for app-level vs. user-level tokens differently.audience, Microsoft Entra ID uses scope with .default suffix, Okta uses scope directly. Always check your provider's documentation.