Client Credentials: Machine-to-Machine
The flow with no user in it — one service authenticating as itself to call another.
Every other OAuth flow involves a human clicking "Allow". Client credentials has no human at all. A service presents its own client id and secret and receives a token representing itself.
The whole flow
POST /token
grant_type=client_credentials
client_id=reporting-service
client_secret=...
scope=orders:read invoices:read
→ { "access_token": "...", "expires_in": 3600, "token_type": "Bearer" }One request. No redirect, no browser, no consent screen — there is no user whose consent could be sought.
How the token differs
There is no sub naming a person; the subject is the client. So authorization is entirely scope-based: what this service may do, full stop. And that means row-level ownership checks do not apply — a machine token typically sees everything within its scope, which is exactly why scopes must be narrow.
Why bother, if it is just an API key?
It is an API key with three improvements:
- The long-lived secret is used rarely — once an hour to mint a token — while short-lived tokens do the actual work. A leaked token expires; a leaked API key does not.
- Scopes are explicit and per-token.
- It is standard, so every gateway, library and provider already speaks it.
Better: drop the secret
Two upgrades worth knowing. Private key JWT (RFC 7523) has the client sign a short JWT assertion with its private key instead of sending a shared secret — nothing reusable crosses the wire. And in cloud environments, workload identity federation lets a pod or function exchange its platform-issued identity token for an access token, so there is no static credential to store at all.
Cache the token
Fetching a new token for every outbound call multiplies your latency and hammers the authorization server. Cache until shortly before expiry, and make sure concurrent callers share one in-flight fetch.
Example
# One request, no browser, no user
curl -X POST https://auth.dfg.com/oauth/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=client_credentials \
-d client_id=reporting-service \
-d client_secret="$CLIENT_SECRET" \
-d scope='orders:read invoices:read'
# {"access_token":"eyJhbGci...","token_type":"Bearer","expires_in":3600,
# "scope":"orders:read invoices:read"}
# Then call the API as usual
curl https://dfg.com/api/orders -H "Authorization: Bearer $TOKEN"
# The token's subject is the SERVICE:
# { "sub": "reporting-service", "scope": "orders:read invoices:read" }
# — no user id, because there is no user.When to use it
- A nightly reporting job mints an hour-long token with read-only scopes, so a leaked log line exposes a credential that is already expired.
- An internal service calls three other services with distinct scopes per target, limiting what a compromise of it could reach.
- A Kubernetes workload federates its projected service account token for an access token, removing every static secret from the deployment.
More examples
A caching token provider
One provider instance per (audience, scope) pair keeps tokens narrow. A single all-scopes token shared across the service is the easy shortcut that widens every breach.
class ServiceTokenProvider {
#token = null;
#expiresAt = 0;
#inFlight = null;
constructor({ tokenUrl, clientId, clientSecret, scope }) {
Object.assign(this, { tokenUrl, clientId, clientSecret, scope });
}
async get() {
// Refresh 60s early so a call never leaves with a token about to expire.
if (this.#token && Date.now() < this.#expiresAt - 60_000) return this.#token;
// Concurrent callers share one fetch instead of stampeding the auth server.
this.#inFlight ??= this.#fetch().finally(() => { this.#inFlight = null; });
return this.#inFlight;
}
async #fetch() {
const res = await fetch(this.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
scope: this.scope,
}),
});
if (!res.ok) throw new Error(`token_request_failed_${res.status}`);
const { access_token, expires_in } = await res.json();
this.#token = access_token;
this.#expiresAt = Date.now() + expires_in * 1000;
return access_token;
}
}
const orders = new ServiceTokenProvider({
tokenUrl: 'https://auth.dfg.com/oauth/token',
clientId: 'reporting-service',
clientSecret: process.env.OAUTH_CLIENT_SECRET,
scope: 'orders:read', // one provider per target scope
});
const res = await fetch('https://dfg.com/api/orders', {
headers: { Authorization: `Bearer ${await orders.get()}` },
});Private key JWT: no shared secret at all
This is what to reach for when a shared client secret would have to be distributed to several environments — the private key stays where it was generated.
import jwt from 'jsonwebtoken';
// RFC 7523: the client proves itself by SIGNING a short-lived assertion.
// The authorization server holds only the public key.
function clientAssertion() {
const now = Math.floor(Date.now() / 1000);
return jwt.sign(
{
iss: 'reporting-service', // the client
sub: 'reporting-service', // ...also the subject
aud: 'https://auth.dfg.com/oauth/token', // bind it to this endpoint
jti: crypto.randomUUID(), // single use
iat: now,
exp: now + 60, // valid for one minute
},
PRIVATE_KEY,
{ algorithm: 'RS256', keyid: 'svc-k1' },
);
}
const res = await fetch('https://auth.dfg.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'orders:read',
client_assertion_type:
'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: clientAssertion(),
}),
});
// Nothing replayable crosses the network: the assertion is audience-bound,
// single-use and expires in 60 seconds.
Discussion