The OAuth 2.0 Client Credentials Grant: A Service Signs In as Itself
By Sergey Nosov
16 October 2026
Suppose a report job runs every night at 2:00 a.m. It needs the previous day’s orders from an orders API, and nobody is awake to sign in. The job has to prove who it is on its own, and the API has to decide what the job may read.
The quick answer is a static API key in a configuration file. OAuth 2.0 has a better one for this case: the client credentials grant. Getting a token takes one request. The job sends its own credentials to an authorization server and gets back a short-lived access token. That token speaks for the job, not for any user. This is the point of the grant, and it is also where the risk sits: no user’s permissions limit what the token can do, only the job’s own.
This article explains when the grant fits and walks through the flow on Keycloak 26.8.1, the current release as of October 2026. The code is written for Node.js 24, and every sample was tested against that server. The article then covers what the token carries, how the API checks it, how the client proves who it is without a shared secret, and when to use something else.
What the Grant Is For
RFC 6749, the OAuth 2.0 framework published in October 2012, defines the grant in section 4.4. A client may request a token “using only its client credentials (or other supported means of authentication).” The resources must be under the client’s own control, or belong to another resource owner and be “previously arranged with the authorization server.” No user signs in, and nobody approves anything at run time.
The same section allows only one kind of client: “The client credentials grant type MUST only be used by confidential clients.” A confidential client can keep its credentials secret, like a service on a server with restricted access. A browser app or a mobile app cannot, because anything it holds sits on a device its user controls.
That makes the grant a fit for callers that run with no one at the keyboard:
- one internal service calling another;
- background jobs, such as daemons, scheduled tasks, and batch processing;
- scripts and command-line tools that run unattended;
- CI/CD pipelines that deploy software or report results;
- a partner’s system that calls your API under its own identity.
No user does not mean no user data. Microsoft Graph shows the difference in its permissions reference. The delegated Mail.Read permission “Allows the app to read the signed-in user’s mailbox.” The application permission of the same name “Allows the app to read mail in all mailboxes without a signed-in user.”
A service identity can be the most powerful identity in a system. In January 2024, Microsoft reported that the Midnight Blizzard group had compromised “a legacy test OAuth application that had elevated access to the Microsoft corporate environment.” The attackers used it to grant their own applications the Exchange Online full_access_as_app role, “which allows access to mailboxes.” Treat each client credential as a key to everything its client can reach.
The Flow on a Real Server
1. The client asks for a token. It sends a POST request over HTTPS to the token endpoint, which in Keycloak is {issuer}/; the issuer is the realm’s URL. The body is a form with the grant type and the client’s credentials. The report job’s request body, shown on three lines, looks like this:
grant_type=client_credentials
&client_id=report-job
&client_secret=s3cr%25t%2Bjob
The secret is s3cr%t+job. A secret set by hand or issued by another server can contain characters like these, and form encoding turns % into %25 and + into %2B. Build the body with a form encoder such as URLSearchParams. In testing, building the body by string concatenation, with the secret left raw, produced a server error.
2. The server authenticates the client and issues a token. Keycloak answered that request with this:
{
"access_token": "eyJhbGci...",
"expires_in": 300,
"refresh_expires_in": 0,
"token_type": "Bearer",
"not-before-policy": 0,
"scope": "email profile"
}
There is no refresh token. RFC 6749 says, “A refresh token SHOULD NOT be included,” and Keycloak’s documentation describes its default: “No refresh token is returned and no user session is created.” A refresh token would add nothing, because the client can always authenticate again. The token lasts 300 seconds, the default for a new realm. The refresh_expires_in and not-before-policy fields are Keycloak’s own additions.
3. The client calls the API over HTTPS, with the token in an Authorization: Bearer header. Never put a token in a URL. RFC 6750 warns that such a URL will likely be logged. RFC 9700, the OAuth security best current practice from January 2025, is blunter: “Clients MUST NOT pass access tokens in a URI query parameter.”
4. The API checks the token and decides whether this caller may do this thing.
Two Ways to Send the Secret
A client with a secret can send it in the form body or in an HTTP Basic Authorization header. RFC 6749 prefers the header. Its section 2.3.1 requires servers to support Basic and calls the body parameters “NOT RECOMMENDED.”
The OAuth 2.1 draft reverses that. Draft 16, published on 3 September 2026 and still a working group document, requires servers to accept the secret in the body and makes Basic optional. The reason is an encoding step inside the Basic rule: the client ID and the secret must each be form-encoded before they are joined and Base64-encoded. The draft says this rule “has led to many interoperability problems in the past”: some implementations skipped the encoding, encoded only some characters, or ignored it when validating credentials.
Keycloak 26.8.1 follows the original rule and decodes the Basic credentials. Sent raw in a Basic header, s3cr%t+job made the server answer 400 with “Unexpected error when authenticating client.” With only the % encoded, the + turned into a space, and the secret no longer matched. Only the fully encoded secret worked. Send the secret in the body unless your server requires Basic, and if it does, form-encode both values first.
What the Token Carries
Keycloak’s access tokens are JSON Web Tokens, so anyone holding one can read its claims by decoding the middle segment. These are the report job’s claims that matter, from the realm set up below:
{
"aud": "orders-api",
"azp": "report-job",
"client_id": "report-job",
"resource_access": {
"orders-api": {
"roles": ["orders.read"]
}
},
"preferred_username":
"service-account-report-job"
}
The aud claim, the audience, names the API the token is for. The azp and client_id claims name the client that asked for it, and resource_access holds client roles, here one role that the orders API defines. Keycloak gives each client a built-in service account, and the token describes that account: preferred_username is its name, and sub holds its generated ID. That fits RFC 9068, the JWT profile for access tokens: with no user involved, sub should identify the client.
The setup takes two clients in the admin console. The orders API’s client defines the orders.read role. On the report job’s client, turn on Client authentication, which makes it a confidential client, and Service account roles, which enables the grant. Turn off Standard flow, which a new client has on, and leave the other flows off. Then give the service account the orders.read role on the client’s Service account roles tab.
The audience follows from that role. The realm’s default roles client scope includes an Audience Resolve mapper, which adds a client’s ID to the audience whenever the token carries one of that client’s roles. Because the report job’s token holds an orders API role, its aud claim names orders-api.
One more default needs to change: a new client in Keycloak 26.8.1 still has Full Scope Allowed turned on. With it, the report job’s token also carried the realm’s default roles, such as offline_access and the account client’s manage-account, plus a second audience, account. The job needed none of them; its service account holds them only because the realm gives every new account its default roles.
Keycloak’s documentation on role scope mappings now calls the switch deprecated and warns that leaving it on “unnecessarily widens the blast radius if a token is compromised.” Turn it off on the client’s dedicated scope, and map only the roles the job needs. A token then holds only the roles in both places: those assigned to the service account and those in the scope mappings of the client and its linked client scopes. With the switch off and one mapping, the token shrank to the one audience and one role shown above.
The Client: Cache, Renew Early, Retry Once
A client that fetches a new token before every call doubles its requests and loads the authorization server for nothing. The usual pattern is to cache the token and renew it shortly before it expires. This is the report job’s client for Node.js 24, which has fetch built in:
const tokenUrl = `${process.env.ISSUER}/protocol/openid-connect/token`;
const clientId = process.env.CLIENT_ID;
const clientSecret = process.env.CLIENT_SECRET;
// The cached token, if any
let cached = null;
// A token request in flight
let pending = null;
async function requestToken() {
const res = await fetch(tokenUrl, {
method: "POST",
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
}),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) {
await res.body?.cancel();
throw new Error(`Token request failed: ${res.status}`);
}
const { access_token, expires_in } = await res.json();
// Renew 30 s early (or halfway,
// for a token under a minute).
const marginMs = Math.min(30, expires_in / 2) * 1000;
cached = {
token: access_token,
refreshAt: Date.now() + expires_in * 1000 - marginMs,
};
return access_token;
}
function getToken() {
if (cached && Date.now() < cached.refreshAt) {
return Promise.resolve(cached.token);
}
pending ??= requestToken().finally(() => { pending = null; });
return pending;
}
export async function callApi(url) {
for (let attempt = 1; ; attempt++) {
const token = await getToken();
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(10000),
});
// Renew and retry once.
if (res.status === 401 && attempt === 1) {
await res.body?.cancel();
if (cached?.token === token) cached = null;
continue;
}
return res;
}
}
The margin renews a token 30 seconds early, or halfway through its life if it lasts under a minute, to keep requests from leaving with a token about to expire. A 10-second test token was reused at 4 seconds and renewed at 6. The pending promise makes callers that find no fresh token share one token request: twenty concurrent calls made one.
A 401 from the API triggers one renewal and one retry. A server can revoke a token early, and the OAuth 2.1 draft says that “clients MUST NOT expect an access token to be valid for the provided lifetime.” More than one retry would hide a real fault. A call clears the cache only if the cached token is the one that failed, not a newer one. Against a mock server, twenty calls whose 401s arrived over 0.6 seconds made five token requests without that check and one with it.
The unread 401 body is canceled because undici, the library behind fetch in Node.js, advises you to “always either consume or cancel the response body.” A failed token request cancels its body too, then throws. A 400 or 401 from the token endpoint means wrong credentials or a wrong configuration, and repeating the request will not fix it. A 503 or a timeout may be temporary; the idempotency article on this site shows how to retry with backoff.
The secret comes from the environment here. In production, read it from a secret store, and never ship it to a browser or a phone.
The API: Check the Audience and the Role
A valid signature proves that the token came unaltered from the authorization server. It does not prove that the token was meant for this API, or that the caller may do what it asks. RFC 9700 recommends audience-restricted tokens and, to put that into effect, says every resource server “is obliged to verify, for every request,” that the token was meant for it. The second question is the role or scope that the operation needs.
This is the orders API’s check, written with the jose library, version 6.2.12:
import { createRemoteJWKSet, jwtVerify } from "jose";
const issuer = process.env.ISSUER;
const jwks = createRemoteJWKSet(
new URL(`${issuer}/protocol/openid-connect/certs`),
);
export async function authorize(authorization, role) {
const match = /^Bearer (\S+)$/i.exec(authorization ?? "");
if (!match) return { status: 401 };
let claims;
try {
({ payload: claims } = await jwtVerify(match[1], jwks, {
issuer,
audience: "orders-api",
algorithms: ["RS256"],
requiredClaims: ["exp"],
}));
} catch {
return { status: 401 };
}
const roles = claims.resource_access?.["orders-api"]?.roles ?? [];
if (!roles.includes(role)) return { status: 403 };
return { status: 200, caller: claims.azp };
}
createRemoteJWKSet fetches the realm’s public keys and caches them. jwtVerify checks the signature against them and rejects a token with the wrong issuer or audience, or one that has expired. The requiredClaims option makes it reject a token with no expiry at all. The algorithms option allows only RS256, the algorithm the realm signs with, so a token signed any other way fails.
The catch block answers 401 for each of these failures, even a failure to fetch the keys. Production code should answer 503 there, so that callers do not renew good tokens. Then the role decides.
Against the test realm, a token issued to another client for another audience got 401. So did a token with an edited payload and an unsigned one that claims the algorithm none, which RFC 9068 requires rejecting. A token with the right audience but no role got 403. RFC 6750 names the errors to send back in the WWW-Authenticate header: invalid_token with a 401 and insufficient_scope with a 403. The STRIDE article on this site checks a user’s token the same way in ASP.NET Core, then checks ownership instead of a role.
If one API serves both users and services, RFC 9700 warns that “a resource server may mistake a client’s identity for the identity of a resource owner.” Decide by role, give roles meant for machines only to service accounts, and before an ownership check, require a role that only people hold.
RFC 9068 adds one more check: resource servers must require the header type at+jwt or application/at+jwt, which helps them tell an access token from an ID token. Keycloak uses the header type JWT unless the client that requests the token has its Use “at+jwt” as access token header type option turned on. Once every caller does, the API can add typ: "at+jwt" to its options.
When to Introspect
Local validation has a blind spot: it cannot see changes made after the token was issued. Token introspection, defined in RFC 7662, lets the API ask the authorization server whether a token is still active. In testing, the local check kept accepting a revoked token, a token whose client was disabled, and one whose service account lost its role. Introspection reported the first two as inactive at once; the third stayed active, but the answer no longer listed its role.
Introspection costs a network call per request, or a cache that brings the blind spot back, and the API needs credentials of its own to make the call. Short-lived tokens keep the blind spot small: a 5-minute token works for at most 5 minutes after any such change. Where that is too long, introspect, and if the introspection call fails, refuse the request.
The introspection standard says the authorization server must check whether a token limited to certain resource servers “can be used at the resource server making the introspection call.” Keycloak did not until version 26.6.2, released on 19 May 2026; its upgrading guide says, “Previously, any authenticated client could introspect any valid token.”
Now the answer is {"active": false} unless the caller appears in the token’s audience. A deprecated option restores the old behavior; keep it off. An active answer means only that the token is valid and meant for the caller. The role remains the API’s question.
My client-credentials-grant-demo repository runs the whole flow, with a user interface, against Keycloak in Docker. One API issues credentials to partners and protects a resource; a partner app uses them to call it. The code is a demonstration, not a template: the API accepts any token that introspection reports as active, with no role check. It also creates clients for any caller by signing in as Keycloak’s administrator.
Replace the Shared Secret, Bind the Token
A client secret is a password. The authorization server stores it, the client stores it, and anyone who copies it can act as the client until someone notices and rotates it. RFC 9700 says, “It is RECOMMENDED to use asymmetric cryptography for client authentication,” such as mutual TLS or signed JWTs. Then the authorization server stores no secret.
With a signed JWT, a method known as private_key_jwt, the client keeps a private key and signs a short-lived assertion for each token request. Keycloak calls this the Signed JWT client authenticator. It reads the client’s public key from a JWKS URL, the option its documentation recommends, or from an imported key, and it never stores the private key. This module builds the report job’s token request body with an ES256 key:
import { readFileSync } from "node:fs";
import { importJWK, SignJWT } from "jose";
const issuer = process.env.ISSUER;
const clientId = process.env.CLIENT_ID;
const keyFile = process.env.CLIENT_KEY_FILE;
const jwk = JSON.parse(readFileSync(keyFile, "utf8"));
const privateKey = await importJWK(jwk, "ES256");
export async function tokenRequestBody() {
const assertion = await new SignJWT({})
.setProtectedHeader({
alg: "ES256",
kid: jwk.kid,
typ: "client-authentication+jwt",
})
.setIssuer(clientId)
.setSubject(clientId)
.setAudience(issuer)
.setJti(crypto.randomUUID())
.setIssuedAt()
.setExpirationTime("60s")
.sign(privateKey);
return new URLSearchParams({
grant_type: "client_credentials",
client_assertion_type:
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
client_assertion: assertion,
});
}
The audience is the part to get right. Researchers at the University of Stuttgart found a vulnerability in several OAuth and OpenID specifications, caused by ambiguous audience values in assertions sent to authorization servers. It was disclosed to the OAuth working group in January 2025. The update to RFC 7523 that followed was approved and is in the RFC Editor’s queue as of October 2026. It requires the authorization server’s issuer identifier as the only audience and says the token endpoint URL “MUST NOT be used as an audience value.” It also asks for the header type client-authentication+jwt.
Keycloak 26.8.1 accepted the report job’s assertion. It refused one with two audiences (“Multiple audiences not allowed”) and a second use of the same assertion (“Token reuse detected”). It still accepted the token endpoint URL as the audience, so the server will not stop you from using the old form. Use the issuer anyway.
Mutual TLS is the other asymmetric option. Specified for OAuth in RFC 8705, it authenticates the client with an X.509 certificate during the TLS handshake and can bind the issued token to that certificate. Keycloak offers it as the X509 Certificate authenticator.
Federation goes further and removes the long-lived secret. CI/CD platforms and Kubernetes clusters can issue their workloads short-lived signed identity tokens, which an authorization server can accept as the client’s proof. GitHub’s documentation for Actions sums up the benefit: “You won’t need to duplicate your cloud credentials as long-lived GitHub secrets.”
Keycloak has supported this since version 26.6, released on 8 April 2026, as federated client authentication. It accepts client assertions from external OpenID Connect providers and Kubernetes service account tokens. Each Keycloak client names the identity provider it trusts and the subject it expects, since one provider serves many workloads.
Last, a token copied from a log works for anyone who holds it; that is what “bearer” means. RFC 9700 says servers “SHOULD use mechanisms for sender-constraining access tokens,” such as mutual TLS or DPoP. DPoP, defined in RFC 9449, binds a token to a key pair the client generates. Keycloak supports it and has a guide.
A stolen sender-constrained token is useless without the client’s private key, but only at an API that verifies the caller holds that key. In testing, the orders API’s check accepted a DPoP-bound token as a plain bearer token.
When Not to Use It
The grant fits only when no user is involved and the client can keep a credential secret. Other flows cover the rest.
- A user signs in. Use the authorization code grant with PKCE, defined in RFC 7636. RFC 9700 requires PKCE for public clients and recommends it for confidential ones.
- The client runs on the user’s device. A single-page app, a mobile app, or a desktop app is a public client. Any secret it ships with can be extracted, so it uses the authorization code grant with PKCE as well.
- The device has no browser or no practical keyboard. The device authorization grant in RFC 8628 serves devices “like smart TVs, media consoles, digital picture frames, and printers.” The user approves on a phone or a computer.
- A service acts for a user. When a service handling a user’s request calls a second service with its own client credentials, the call carries the service’s authority, not the user’s. Pass the user’s authority on instead, in a token meant for the second service. Token exchange, defined in RFC 8693, does this, and Keycloak has supported it since version 26.2.
Other Ways to Authenticate a Service
OAuth is not the only option, and the right one depends on what your platform already trusts.
- API keys. The simplest choice is one static value in a header. A plain key carries no expiry and no scope of its own. Whatever limits it has live in the system that issued it, and rotating it means touching every caller that holds it. Keep keys out of URLs, for the same reason as tokens.
- Mutual TLS. Both sides present X.509 certificates. It is strong and suits service meshes and zero-trust networks, at the cost of issuing, distributing, and rotating certificates. OAuth can build on it, as described earlier.
- Signed requests. The client signs each request, so the server can reject one whose signed parts were altered in transit. Signature Version 4 is the AWS signing protocol for API requests, and RFC 9421, published in February 2024, standardizes signatures for HTTP messages.
- SAML 2.0 assertions. Where an organization already federates identity with SAML, RFC 7522 lets a SAML assertion request an OAuth token. The pending update described above also bars SAML assertions for client authentication in new applications.
- Kerberos. Clients prove who they are with tickets from a key distribution center, which in Windows Server, Microsoft says, “runs on every domain controller.” Kerberos suits services already in an Active Directory domain.
A Checklist
My Software Development Principles series has entries on least privilege, secrets management, and zero trust, with the signs that each is applied well or badly. For the client credentials grant, the article comes down to this:
- Use the grant only for confidential clients, with no user in the flow.
- Give each caller its own client, and turn off the flows it does not use.
- Map only the roles each client needs; in Keycloak, turn off Full Scope Allowed.
- In the API, check the signature, the algorithm, the issuer, the audience, and the expiry, then the role.
- Send tokens only over HTTPS, in the
Authorizationheader, never in a URL. - Cache tokens, renew them early, and retry a 401 once.
- Prefer signed JWTs, mutual TLS, or federated credentials to shared secrets, and make the authorization server’s issuer each assertion’s only audience.
- Keep an inventory of clients, and delete the ones nobody uses.
Further Reading
- The OAuth 2.0 Authorization Framework, RFC 6749 (IETF, October 2012)
- Best Current Practice for OAuth 2.0 Security, RFC 9700 (IETF, January 2025)
- The OAuth 2.1 Authorization Framework (IETF draft; version 16, 3 September 2026)
- Updates to OAuth 2.0 JSON Web Token (JWT) Client Authentication and Assertion-Based Authorization Grants (IETF draft, approved; version 11, 26 March 2026)
- JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens, RFC 9068 (IETF, October 2021)
- Keycloak Server Administration Guide: Using a Service Account (Keycloak)