Skip to main content

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 caseRecommended
Long-lived backend integrationAPI key (simpler)
Short-lived service-to-serviceOAuth2 CC (tokens expire, safer)
Third-party integration with scoped accessOAuth2 CC (scopes)
Compliance / rotating credentialsOAuth2 CC (tokens auto-expire)

Step 1 — Create an OAuth client

  1. Sign in to the AuthzX Console.
  2. Go to Settings > API > OAuth Clients.
  3. 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.
  4. 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 — always Bearer.
  • 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

ScopeGrants
readRead-only endpoints (GET: list, detail, authorize checks)
writeEverything in read, plus POST, PUT, PATCH, DELETE (CRUD)
adminEverything 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:

  1. Request a token.
  2. Cache it in memory until roughly 60 seconds before expires_in.
  3. On expiry (or on a 401 response), request a new token with the same client_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_secret in 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 read over write, and write over admin.
  • Monitor token issuance in the audit log (Settings > Audit, filter by OAuth client) to detect unusual issuance patterns.
  • Do not log tokens. Strip Authorization headers from request logs.

Error responses

Errors follow the OAuth2 standard error format:

{
"error": "invalid_client",
"error_description": "client authentication failed"
}
StatuserrorMeaning
400invalid_requestMissing grant_type, client_id, or client_secret, or the body is not form-encoded
400unsupported_grant_typeOnly client_credentials is supported at this endpoint
401invalid_clientclient_id or client_secret is wrong, or the client has been revoked
403insufficient_scopeThe 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.