Client credentials for services

OAuth, OIDC & JWT · Practical · 10 min

Machine-to-machine calls between services, jobs and partners use this grant.

When no user is involved (a nightly job calling a billing API, one microservice calling another), the client acts on its own behalf. The client credentials grant is a single back-channel request:

POST /token with grant_type=client_credentials, the requested scope, and the client's authentication.

There is no refresh token: when the access token expires, the service simply requests a new one. Cache it until shortly before expires_in runs out instead of fetching per request; many authorization servers rate-limit the token endpoint.

How the client authenticates matters:

  • client_secret_basic (HTTP Basic with id and secret) or client_secret_post are simple but rely on a shared secret. Keep it in a secret manager, inject it as an environment variable, and rotate it.
  • private_key_jwt: the client signs a short-lived JWT assertion with its private key; the AS knows only the public key. No shared secret to leak.
  • mTLS client authentication (RFC 8705) uses a client certificate.
  • In clouds and Kubernetes, workload identity federation lets services exchange platform-issued tokens instead of holding any long-lived secret.

The resulting token's sub is the client itself, so APIs authorize based on the client's scopes or roles rather than a user.

Example

// Fetch and cache a client-credentials token (Node 22+, global fetch)
let cached = { token: null, expiresAt: 0 };

async function getServiceToken() {
  if (cached.token && Date.now() < cached.expiresAt - 60_000) return cached.token;
  const res = await fetch("https://auth.example.com/oauth/token", {
    method: "POST",
    headers: {
      Authorization: "Basic " + Buffer.from(
        `${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`).toString("base64"),
    },
    body: new URLSearchParams({ grant_type: "client_credentials", scope: "invoices.read" }),
  });
  if (!res.ok) throw new Error(`token endpoint: ${res.status}`);
  const { access_token, expires_in } = await res.json();
  cached = { token: access_token, expiresAt: Date.now() + expires_in * 1000 };
  return access_token;
}
// await fetch(url, { headers: { Authorization: `Bearer ${await getServiceToken()}` } })

Remember

  • Client credentials = the service acting as itself, no user
  • No refresh token: cache the access token until near expiry
  • Prefer private_key_jwt, mTLS or workload identity over shared secrets

Watch out for

  • Requesting a new token on every API call and hitting token endpoint rate limits.

Check yourself

  1. Why does the client credentials grant not return a refresh token?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.rfc-editor.org/rfc/rfc6749#section-4.4