OAuth2 Client Credentials
The OAuth2 Client Credentials grant is a machine-to-machine authentication flow standardized by RFC 6749. Instead of a long-lived API key, a client exchanges a client_id and client_secret for a short-lived access token, then uses that token to call AuthzX APIs.
Use this flow when:
- A backend service needs to call AuthzX on its own behalf (no end user is involved).
- You are wiring AuthzX into a CI/CD pipeline that provisions resources or rotates policies.
- You are building an internal tool, cron job, or worker that needs scoped, time-bounded access.
- You are exposing an MCP server to AI agents and want each agent instance to authenticate with its own credentials.
AuthzX handles authorization — it does not authenticate end users. Bring your own identity provider (Auth0, Clerk, Cognito, your own login), then pass the authenticated user's ID as the subject when calling /v1/authorize.
When to use OAuth2 vs API keys
| Use case | Recommended |
|---|---|
| Long-lived backend integration | API key (simpler) |
| Short-lived service-to-service | OAuth2 CC (tokens expire, safer) |
| Third-party integration with scoped access | OAuth2 CC (scopes) |
| Compliance / rotating credentials | OAuth2 CC (tokens auto-expire) |
Step 1 — Create an OAuth client
- Sign in to the AuthzX Console.
- Go to Settings > API > OAuth Clients.
- Click Create Client and fill in:
- Name — a human-readable label (for example,
ci-deploy-bot). - Description — optional context about what the client is for.
- Scopes — any combination of
read,write,admin.
- Name — a human-readable label (for example,
- Click Save.
You will be shown a client_id and a client_secret of the form:
client_id: azx_ci_01HXYZ...
client_secret: azx_cs_9f3a8b4c2d1e...
Copy the client_secret immediately. For security reasons it is shown only once. If you lose it, regenerate a new secret from the same client entry in the Console.
Step 2 — Request an access token
Exchange the client credentials for an access token at the token endpoint:
curl -X POST https://api.authzx.com/identity-srv/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=azx_ci_01HXYZ..." \
-d "client_secret=azx_cs_9f3a8b4c2d1e..."
A successful response looks like:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read write"
}
access_token— the bearer token to send with subsequent API calls.token_type— alwaysBearer.expires_in— lifetime in seconds. Re-request a token before or after expiry.scope— the scopes granted to this token.
Step 3 — Use the token
Pass the access token in the Authorization header on every request:
curl -X POST https://api.authzx.com/v1/authorize \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6..." \
-H "Content-Type: application/json" \
-d '{
"subject": { "id": "user-123", "type": "user" },
"resource": { "id": "doc-456", "type": "document" },
"action": { "name": "read" }
}'
The same token works against the Management API, the Authorize API, and any other AuthzX endpoint — subject to the scopes on the token.
Scopes
| Scope | Grants |
|---|---|
read | Read-only endpoints (GET: list, detail, authorize checks) |
write | Everything in read, plus POST, PUT, PATCH, DELETE (CRUD) |
admin | Everything in write, plus administrative endpoints (client management, audit, tenant settings) |
Scopes are cumulative: admin implies write, and write implies read. Request only the minimum scopes your integration needs.
Token lifetime
- Default: 1 hour (
expires_in: 3600). - Configurable per client in the Console, between 5 minutes and 24 hours.
Shorter lifetimes reduce the blast radius if a token leaks; longer lifetimes reduce token-endpoint traffic. One hour is a reasonable default for most backend integrations.
Refreshing tokens
The Client Credentials flow does not use refresh tokens. This is intentional and matches the OAuth2 specification — since the client already holds its own credentials, it can simply request a new token whenever one expires.
Typical pattern:
- Request a token.
- Cache it in memory until roughly 60 seconds before
expires_in. - On expiry (or on a
401response), request a new token with the sameclient_id/client_secret.
Example: Node.js
let cached = null;
async function getToken() {
if (cached && cached.expiresAt > Date.now() + 60_000) {
return cached.token;
}
const res = await fetch("https://api.authzx.com/identity-srv/v1/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.AUTHZX_CLIENT_ID,
client_secret: process.env.AUTHZX_CLIENT_SECRET,
}),
});
if (!res.ok) {
throw new Error(`token request failed: ${res.status} ${await res.text()}`);
}
const { access_token, expires_in } = await res.json();
cached = {
token: access_token,
expiresAt: Date.now() + expires_in * 1000,
};
return cached.token;
}
async function authorize(input) {
const token = await getToken();
const res = await fetch("https://api.authzx.com/v1/authorize", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return res.json();
}
Example: Python
import os
import time
import requests
_TOKEN_URL = "https://api.authzx.com/identity-srv/v1/oauth/token"
_cache = {"token": None, "expires_at": 0}
def get_token() -> str:
if _cache["token"] and _cache["expires_at"] > time.time() + 60:
return _cache["token"]
resp = requests.post(
_TOKEN_URL,
data={
"grant_type": "client_credentials",
"client_id": os.environ["AUTHZX_CLIENT_ID"],
"client_secret": os.environ["AUTHZX_CLIENT_SECRET"],
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
resp.raise_for_status()
body = resp.json()
_cache["token"] = body["access_token"]
_cache["expires_at"] = time.time() + body["expires_in"]
return _cache["token"]
def authorize(payload: dict) -> dict:
resp = requests.post(
"https://api.authzx.com/v1/authorize",
headers={
"Authorization": f"Bearer {get_token()}",
"Content-Type": "application/json",
},
json=payload,
timeout=10,
)
resp.raise_for_status()
return resp.json()
Example: Go
package authzx
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
const tokenURL = "https://api.authzx.com/identity-srv/v1/oauth/token"
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}
var (
mu sync.Mutex
cachedTok string
expiresAt time.Time
)
func GetToken() (string, error) {
mu.Lock()
defer mu.Unlock()
if cachedTok != "" && time.Until(expiresAt) > time.Minute {
return cachedTok, nil
}
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", os.Getenv("AUTHZX_CLIENT_ID"))
form.Set("client_secret", os.Getenv("AUTHZX_CLIENT_SECRET"))
req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("token request failed: %d %s", resp.StatusCode, string(body))
}
var tr tokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return "", err
}
cachedTok = tr.AccessToken
expiresAt = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second)
return cachedTok, nil
}
Security best practices
- Store
client_secretin a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, Kubernetes Secrets) — never in source code or unencrypted config files. - Rotate secrets periodically. From the Console, open the client and click Regenerate secret. Deploy the new secret, then revoke the old one.
- Use separate clients per environment (development, staging, production) so a leaked dev secret cannot reach production data.
- Limit scopes to the minimum required. Prefer
readoverwrite, andwriteoveradmin. - Monitor token issuance in the audit log (Settings > Audit, filter by OAuth client) to detect unusual issuance patterns.
- Do not log tokens. Strip
Authorizationheaders from request logs.
Error responses
Errors follow the OAuth2 standard error format:
{
"error": "invalid_client",
"error_description": "client authentication failed"
}
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_request | Missing grant_type, client_id, or client_secret, or the body is not form-encoded |
| 400 | unsupported_grant_type | Only client_credentials is supported at this endpoint |
| 401 | invalid_client | client_id or client_secret is wrong, or the client has been revoked |
| 403 | insufficient_scope | The token is valid but lacks the scope required for the requested action |
Comparison with API keys
Both API keys and OAuth2 Client Credentials authenticate a backend service (not an end user), and both are passed as Authorization: Bearer .... The difference is how the credential is managed:
- An API key is a long-lived static string. Simple to set up, but if it leaks, it remains valid until you revoke it manually.
- An OAuth2 access token is short-lived and derived from a
client_secret. If a token leaks, it expires on its own. Scopes let you grant fine-grained, least-privilege access, and the flow is supported out of the box by most HTTP client libraries (requests-oauthlib,golang.org/x/oauth2/clientcredentials,simple-oauth2, and others).
For new integrations — especially multi-environment, compliance-sensitive, or third-party ones — prefer OAuth2 Client Credentials. API keys remain a good fit for simple, trusted, long-lived internal scripts.