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.
The application type
Section titled “The application type”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
subclaim is the client id, not a user id. - Get a list of allowed scopes — the API surfaces this client is permitted to call.
The flow
Section titled “The flow”A single HTTP POST. Send your credentials, receive a token, use it on subsequent API calls.
POST https://<your-tenant-url>/oauth2/tokenContent-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:readResponse:
{ "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.
How to authenticate the client
Section titled “How to authenticate the client”Two methods, identical security if used correctly:
- Body parameters (shown above).
client_id+client_secretin 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.
Storing the secret
Section titled “Storing the secret”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.
Audiences and scopes
Section titled “Audiences and scopes”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.
Common error responses
Section titled “Common error responses”| Error | Meaning | Fix |
|---|---|---|
invalid_client | client_id is wrong, or secret does not match | Re-check the values, watch for whitespace |
invalid_grant | grant_type is wrong | It must be client_credentials |
invalid_scope | requested scope not in the application's allowed list | Add the scope in the application settings, or drop it from the request |
unauthorized_client | application is disabled, or the wrong type | Check the application state and type in tenant admin |