Skip to Content
Faable AuthOAuth 2.0 FlowsClient Credentials (M2M)

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:

  1. A Machine-to-Machine Client — this gives you the client_id and client_secret. See Clients.
  2. A registered API (the resource server your service will call) — its identifier is the audience you 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-urlencoded or application/json — both are accepted.

Request Parameters

ParameterRequiredDescription
grant_typeYesMust be client_credentials.
client_idYesYour application’s Client ID.
client_secretYesYour application’s Client Secret. Keep it server-side only.
audienceRecommendedThe identifier of the API you want to call. Sets the token’s aud claim and its lifetime.
scopeNoSpace-separated list of permissions to request (e.g. read:users write:users). Must be defined on the target API.

[!TIP] Instead of sending client_id and client_secret in the body, you can send them as an HTTP Basic Authorization header (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" }
FieldDescription
access_tokenA signed JWT (RS256). Its aud claim matches the requested audience.
token_typeAlways Bearer.
expires_inToken lifetime in seconds, controlled by the target API’s token_lifetime setting (default: 86400 = 24 h).
scopeThe 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 429 responses.


🔐 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 scopesthe named scopes that are on the allow-list; the rest are dropped. scope lists what was granted.
names no scopethe whole allow-list — never more.
comes from a client whose list is empty403 access_denied, error_code: management_scopes_empty.
names only scopes that are not on the list400 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_scopes on POST /client or on POST /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": "..." }.

StatuserrorCauseFix
400invalid_requestMissing grant_type or client_id, or a malformed body.Send every required parameter as application/x-www-form-urlencoded in the request body.
400unsupported_grant_typeThe grant_type is not one this endpoint knows.Use client_credentials.
401invalid_clientMissing or wrong client_secret, or an unknown client_id.Check the ID and the domain, verify the secret, or rotate it in the dashboard.
403access_deniedManagement 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.
400invalid_scopeManagement 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_secret must 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.


Last updated on