Skip to content

Client credentials — for machine-to-machine

When your backend talks to another backend, there is no human to redirect through a sign-in page. You need a token, and you need it now. That is what the client-credentials grant is for.

Common shapes for this flow:

  • A nightly job that calls another internal service.
  • A webhook receiver that needs to call the platform back to acknowledge events.
  • A CLI tool used by your operators (where the "user" is the application itself).
  • A microservice that fans out work to other microservices.

In the tenant admin console, register an application with the type Machine-to-machine (M2M). M2M applications:

  • Get a client id (public, paired with the credential).
  • Get a client secret (private, single-show — copy it once, store it somewhere safe, you cannot retrieve it again).
  • Have no user associated. The access token's sub claim is the client id, not a user id.
  • Get a list of allowed scopes — the API surfaces this client is permitted to call.

A single HTTP POST. Send your credentials, receive a token, use it on subsequent API calls.

POST https://<your-tenant-url>/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=<your-client-id>
&client_secret=<your-client-secret>
&audience=<the-api-you-want-to-call>
&scope=audit:read+users:read

Response:

{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "audit:read users:read"
}

No refresh_token is returned. M2M tokens are short-lived; when the current one is close to expiry, repeat the request to get a fresh one.

Two methods, identical security if used correctly:

  • Body parameters (shown above). client_id + client_secret in the POST body. Simple, easy to inspect, what most SDKs use.
  • Basic auth header. Authorization: Basic base64(client_id:client_secret). Same effect; some HTTP clients default to this.

Pick whichever your HTTP library makes natural. Do not include the secret in the URL query string — query strings end up in access logs.

Caching the token (the most important practical detail)

Section titled “Caching the token (the most important practical detail)”

Do not call /oauth2/token on every API request. The token is valid for expires_in seconds; cache it.

A simple pattern (Node):

let cachedToken: { token: string; expiresAt: number } | null = null
async function getAccessToken() {
// Refresh 60s before expiry so we never hand out an about-to-expire token
if (cachedToken && Date.now() < cachedToken.expiresAt - 60_000) {
return cachedToken.token
}
const res = await fetch(`${tenantUrl}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
audience,
scope,
}),
})
const data = await res.json()
cachedToken = {
token: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
}
return cachedToken.token
}

@intelliauth/node-sdk ships this pattern; for other stacks, copy the snippet above.

In a multi-instance backend, share the cache via Redis or another fast store. Otherwise every pod will independently re-request the token, which scales linearly with instance count and is wasteful.

Treat the client secret like a database password:

  • Inject it via an environment variable or a secret manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, Kubernetes Secrets).
  • Never commit it. Never log it. Never put it in a screenshot for a support ticket.
  • Rotate it on a schedule. The application's settings page supports rotation with a brief overlap window.

When a secret leaks, rotate immediately and audit the access token issuance log for unexpected callers.

audience tells the platform which API you intend to call. scope tells it what subset of that API you need. The application can only request scopes that are on its allowed-scopes list — anything outside that list returns invalid_scope.

Treat scopes as least-privilege. A nightly audit-export job needs audit:read, not audit:write.

ErrorMeaningFix
invalid_clientclient_id is wrong, or secret does not matchRe-check the values, watch for whitespace
invalid_grantgrant_type is wrongIt must be client_credentials
invalid_scoperequested scope not in the application's allowed listAdd the scope in the application settings, or drop it from the request
unauthorized_clientapplication is disabled, or the wrong typeCheck the application state and type in tenant admin