Machine-to-machine (M2M) authentication with Client Credentials 🤖
Machine-to-machine (M2M) authentication is how one service proves its identity to another with no user involved — and the OAuth 2.0 Client Credentials flow is the standard way to do it. Your service authenticates with its client_id and client_secret, receives a signed JWT access token — the M2M token — and uses it to call your protected API.
Use it for machine-to-machine (M2M) communication:
- ✅ Microservices calling each other
- ✅ Cron jobs, background workers, and daemons
- ✅ CLIs and CI/CD pipelines calling your API
- ✅ Third-party servers integrating with your platform
Do not use it in browsers or mobile apps — it requires a client_secret, which can never be shipped to a device you don’t control. For user-facing apps, use the Authorization Code flow with PKCE with our @faable/auth-js SDK.
📸 How It Works
One round trip. No redirects, no login screens, no consent prompts.
✅ Prerequisites
From the Faable Dashboard you need:
- A Machine-to-Machine Client — this gives you the
client_idandclient_secret. See Clients. - A registered API (the resource server your service will call) — its
identifieris theaudienceyou request tokens for, and it defines the permissions (scopes) available. See APIs.
🛠️ Step 1: Request an Access Token
Make a POST request to your tenant’s token endpoint:
- Endpoint: https://your-domain.auth.faable.link/oauth/token
- Method:
POST - Content-Type:
application/x-www-form-urlencodedorapplication/json— both are accepted.
Request Parameters
| Parameter | Required | Description |
|---|---|---|
grant_type | Yes | Must be client_credentials. |
client_id | Yes | Your application’s Client ID. |
client_secret | Yes | Your application’s Client Secret. Keep it server-side only. |
audience | Recommended | The identifier of the API you want to call. Sets the token’s aud claim and its lifetime. |
scope | No | Space-separated list of permissions to request (e.g. read:users write:users). Must be defined on the target API. |
[!TIP] Instead of sending
client_idandclient_secretin the body, you can send them as an HTTP BasicAuthorizationheader (client_secret_basic), as described in RFC 6749. Both methods work identically.
Example with curl
curl --request POST \
--url 'https://your-domain.auth.faable.link/oauth/token' \
--header 'content-type: application/x-www-form-urlencoded' \
--data 'grant_type=client_credentials' \
--data 'client_id=YOUR_CLIENT_ID' \
--data 'client_secret=YOUR_CLIENT_SECRET' \
--data 'audience=https://api.example.com' \
--data 'scope=read:users'Response
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 86400,
"scope": "read:users"
}| Field | Description |
|---|---|
access_token | A signed JWT (RS256). Its aud claim matches the requested audience. |
token_type | Always Bearer. |
expires_in | Token lifetime in seconds, controlled by the target API’s token_lifetime setting (default: 86400 = 24 h). |
scope | The scopes actually granted. When the API has enforce_policies enabled, only permissions defined on the API are granted — anything else is dropped. For the Management API, only scopes in the client’s Management API scopes are granted. |
[!NOTE] The Client Credentials flow never returns a
refresh_token— there is no user session to maintain. When the token expires, simply request a new one.
🚀 Step 2: Call Your API with the Token
Send the token in the Authorization header of every request to your API:
curl --request GET \
--url 'https://api.example.com/users' \
--header 'authorization: Bearer eyJhbGciOiJSUzI1NiIs...'Your API validates the token by checking its signature against your tenant’s public keys (https://your-domain.auth.faable.link/.well-known/jwks.json), its aud claim against the API’s identifier, and its scope against the permission the endpoint requires. See Validate Access Tokens for ready-to-use Express middleware, and APIs for the full model.
💻 Complete Node.js Example
A production-ready pattern: fetch the token once, cache it, and refresh it slightly before it expires.
const AUTH_DOMAIN = 'https://your-domain.auth.faable.link'
let cached: { token: string; expiresAt: number } | null = null
async function getAccessToken(): Promise<string> {
// Reuse the cached token, refreshing 60s before expiry
if (cached && cached.expiresAt > Date.now() + 60_000) {
return cached.token
}
const res = await fetch(`${AUTH_DOMAIN}/oauth/token`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.FAABLE_CLIENT_ID!,
client_secret: process.env.FAABLE_CLIENT_SECRET!,
audience: 'https://api.example.com',
scope: 'read:users'
})
})
if (!res.ok) {
const err = await res.json()
throw new Error(`Token request failed: ${err.error}`)
}
const { access_token, expires_in } = await res.json()
cached = { token: access_token, expiresAt: Date.now() + expires_in * 1000 }
return access_token
}
// Use it
const token = await getAccessToken()
const users = await fetch('https://api.example.com/users', {
headers: { authorization: `Bearer ${token}` }
})[!IMPORTANT] Always cache the token until it expires. Requesting a new token on every API call adds latency, and the token endpoint is rate-limited per client — a tight token loop will get throttled with
429responses.
🔐 Calling the Faable Management API
The same flow gets a token for your tenant’s own Management API — users, roles, clients, connections — which is what @faable/auth-sdk does for you. Request the audience faable:management:<account_id> (the SDK discovers it from your domain).
What such a token may carry is decided per client, by the client’s Management API scopes (management_scopes): an allow-list of scopes from the Management API catalog (read:users, update:users, read:rolemembers, create:clients…).
| The token request… | You get |
|---|---|
| names some scopes | the named scopes that are on the allow-list; the rest are dropped. scope lists what was granted. |
| names no scope | the whole allow-list — never more. |
| comes from a client whose list is empty | 403 access_denied, error_code: management_scopes_empty. |
| names only scopes that are not on the list | 400 invalid_scope, error_code: management_scope_not_allowed. |
New clients start with an empty list, so they cannot call the Management API at all: a client that only signs users in, or whose secret sits on a web server, can’t administer your tenant with that secret. Allow scopes only on the clients of backends that need them, and only the ones they use.
Clients created before this allow-list existed could obtain the whole catalog. To keep them working, every existing client that could use Client Credentials was given the whole catalog as its list. Review them and narrow each one to what it actually calls.
Setting a client’s Management API scopes
-
Dashboard: open your Auth tenant → Clients → the client → OAuth tab → Management API scopes, tick what it may do (one row per resource, one column per
read/create/update/delete) and Save scopes. The section is shown for clients that can use Client Credentials (an Any grant client, or one with Client Credentials ticked). When creating a client, the optional Management API access section lets you pick them from the start; the default is none. -
Management API: send
management_scopesonPOST /clientor onPOST /client/:id. On an update it replaces the whole list;[]removes all.curl --request POST \ --url 'https://your-domain.auth.faable.link/client/client_123' \ --header 'authorization: Bearer <management token>' \ --header 'content-type: application/json' \ --data '{ "management_scopes": ["read:users", "read:rolemembers"] }'
Two rules on writing the list: every entry must be in the Management API catalog (otherwise validation_error), and you can only add scopes your own token holds (otherwise forbidden), so a client allowed update:clients cannot use it to give itself the rest of the catalog.
This allow-list applies to the Management API only. Tokens for your own APIs follow that API’s permissions and enforce_policies, as described above.
⚠️ Common Errors
Errors follow the standard OAuth 2.0 error format (RFC 6749 §5.2): { "error": "...", "error_description": "..." }.
| Status | error | Cause | Fix |
|---|---|---|---|
400 | invalid_request | Missing grant_type or client_id, or a malformed body. | Send every required parameter as application/x-www-form-urlencoded in the request body. |
400 | unsupported_grant_type | The grant_type is not one this endpoint knows. | Use client_credentials. |
401 | invalid_client | Missing or wrong client_secret, or an unknown client_id. | Check the ID and the domain, verify the secret, or rotate it in the dashboard. |
403 | access_denied | Management API audience, and the client’s Management API scopes are empty (error_code: management_scopes_empty). | Allow the scopes it needs in the dashboard (client → OAuth → Management API scopes). See above. |
400 | invalid_scope | Management API audience, and none of the requested scopes is in the client’s Management API scopes (error_code: management_scope_not_allowed). | Request scopes that are on the list, or add them to it. |
429 | — | Rate limit exceeded. | You’re requesting tokens too often. Cache the token (see example above). |
🔒 Security Best Practices
- Never use this flow in a browser or mobile app. The
client_secretmust only exist on servers you control. - Store the secret in an environment variable or secret manager — never commit it to source control.
- Request only the scopes you need. Narrow tokens limit the blast radius if one leaks.
- Rotate the secret from the dashboard if you suspect it has been exposed. Rotation invalidates the old secret immediately.
- Use one client per service. Separate credentials make audit logs meaningful and let you revoke a single service without breaking the rest.
❓ FAQ
What is machine-to-machine (M2M) authentication?
Authentication between two pieces of software, with no person signing in: a backend calling another service’s API, a cron job, a CLI in CI. Instead of a user’s login, the calling service presents its own credentials — a client_id and client_secret — and receives an access token (the M2M token) scoped to the API it wants to call.
How much do M2M tokens cost?
Every plan includes 100 machine-to-machine tokens a month per project. On Hobby and Pro, tokens above that are billed at €2.25 per 1,000; on Free the 100 are a cap, and the next request in the month gets 402 m2m_quota_exceeded. Cache each token for its expires_in instead of requesting one per call. See Auth pricing.
How long does the access token last?
It’s defined by the target API’s token_lifetime setting — from 60 seconds up to 30 days, with a default of 24 hours.
Does this flow return a refresh token?
No. There’s no user session to refresh. When the token expires, request a new one with the same credentials.
What’s the difference between Client Credentials and Authorization Code?
Authorization Code authenticates a user (login screen, redirects, consent). Client Credentials authenticates an application — no user, no UI, one HTTP request.
Can I use this flow to call the Faable Management API?
Yes. Create an M2M client, allow the scopes it needs under Management API scopes on the client page, and request a token with the Management API’s audience (@faable/auth-sdk does both the audience and the token for you). A client with no Management API scopes is refused with 403 management_scopes_empty. See Calling the Faable Management API.
What does an M2M client get from the Management API if it requests no scope?
The scopes on its own allow-list (management_scopes), never the whole catalog. A new client’s list is empty, so it gets nothing until you allow scopes on it.
🔗 Related
- Clients — create an M2M client and get your credentials.
- APIs — register your API, define its audience and permissions.
- Authorization Code Flow — for user-facing applications, with the @faable/auth-js SDK.
- API Reference — full OpenAPI specification.
- RFC 6749 §4.4 — Client Credentials Grant — the official OAuth 2.0 standard.
Last updated on