privileged.id

OAuth 2.0

Client credentials grant

How a service gets an access token as itself, how it should prove its identity, and how to keep its tokens narrow and short-lived.

Reviewed 6 min read 7 primary sources

The client credentials grant is OAuth for machines. There is no user and no browser: a backend service, scheduled job or other workload authenticates to the authorization server as itself and receives an access token for an API. It is defined in RFC 6749 §4.4, and it is one of the grants that RFC 9700 and the OAuth 2.1 draft keep unchanged.

Because the client's own credential is the whole grant, the security of this flow is the security of that credential. Most of this page is therefore about how the client authenticates, how narrowly its tokens are scoped, and how it handles them afterwards.

When to use it, and when not to

RFC 6749 says the client can use this grant when it is "requesting access to the protected resources under its control, or those of another resource owner that have been previously arranged with the authorization server". Typical cases: a billing service calling an internal ledger API, a CI pipeline calling a deployment API, a nightly job exporting reports. In each, the service account or workload is the principal, and access was granted administratively, not by a person clicking "Allow".

The client credentials grant type MUST only be used by confidential clients.

That rules out anything that runs on a user's device. A mobile app or single-page app that ships a client secret and uses client credentials is not a confidential client; the secret is public, and anyone can extract it and call the API with the app's full privileges. Apps acting for a user should use the authorization code grant with PKCE; devices without a browser should use the device authorization grant.

Equally, do not use client credentials to act "as" a user by passing a user ID in a parameter. The token then says nothing verifiable about the user, and the API has to trust the calling service completely. RFC 9700 §4.15 describes a related pitfall: if a resource server cannot tell tokens issued to a client from tokens issued for a user, and clients can pick identifiers that collide with user identifiers, a client may be able to access a user's resources. Authorization servers SHOULD NOT let clients influence their client_id in a way that could cause such confusion, and where it cannot be avoided they MUST give resource servers another way to tell the two kinds of tokens apart (§4.15.1).

The flow

Client credentials grant with token caching Client service / workload Authorization server Resource server (API) 1. POST /token + client auth 2. Authenticate client, apply policy 3. Access token, expires_in 4. Cache token until near expiry 5. Request with Authorization: Bearer 6. Validate iss, aud, exp, scope 7. Response
Two network calls do the work: one to the token endpoint (rarely, thanks to caching) and one per API request.

The request carries grant_type=client_credentials, an optional scope, and client authentication (§4.4.2). Here with HTTP Basic, the RFC's own example credentials:

POST /token HTTP/1.1
Host: as.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=ledger.read

The Basic value is base64("s6BhdRkqt3:gX1fBat3bV"); RFC 6749 §2.3.1 says the client ID and secret are each form-urlencoded before being joined and encoded, a detail that breaks clients whose secrets contain characters such as + or %. The response:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache

{
  "access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "ledger.read"
}

There is no refresh token. RFC 6749 §4.4.3: "A refresh token SHOULD NOT be included." It would add nothing: the client can always repeat the grant with its own credentials, and a long-lived refresh token would just be one more secret to leak.

Client authentication methods

The names below are the registered values of token_endpoint_auth_method, which authorization servers list in token_endpoint_auth_methods_supported in their RFC 8414 metadata (default: client_secret_basic).

MethodDefined inWhat the client provesNotes
client_secret_basicRFC 6749 §2.3.1; OIDC Core §9Knowledge of a shared secret, sent in an Authorization: Basic headerServers MUST support it for clients issued a password. The secret travels on every request and is stored by the server.
client_secret_postRFC 6749 §2.3.1; OIDC Core §9The same secret, in the request bodyNOT RECOMMENDED; should be limited to clients that cannot use Basic. Never in the URL (MUST NOT).
client_secret_jwtOIDC Core §9; RFC 7523A JWT MACed with the shared secretThe secret is not sent, but the server still stores it; a symmetric key.
private_key_jwtOIDC Core §9; RFC 7523 §2.2A JWT signed with the client's private keyThe server holds only the public key (often via a JWKS URL), so a server-side leak exposes nothing reusable.
tls_client_authRFC 8705 §2.1Possession of the private key of a CA-issued certificate, in the TLS handshakeMatched on one configured subject DN or SAN. Can also bind tokens to the certificate.
self_signed_tls_client_authRFC 8705 §2.2Possession of a registered self-signed certificateNo PKI needed; certificates registered via jwks or jwks_uri.

RFC 9700 §2.5 says authorization servers SHOULD enforce client authentication where it is feasible to issue and protect client credentials, and that asymmetric methods, mutual TLS or private_key_jwt, are RECOMMENDED: "authorization servers do not need to store sensitive symmetric keys". A client MUST NOT use more than one authentication method in a request (RFC 6749 §2.3).

A private_key_jwt assertion

The client builds and signs a short-lived JWT and sends it as client_assertion. The decoded header and payload, with an illustrative key ID and times (signature omitted):

{"alg":"ES256","typ":"JWT","kid":"ledger-exporter-2026-10"}
.
{
  "iss": "s6BhdRkqt3",
  "sub": "s6BhdRkqt3",
  "aud": "https://as.example.com",
  "jti": "b1f0c3b6-6a8e-4f1e-9d5c-0e3c2f7a9a41",
  "iat": 1793016000,
  "exp": 1793016060
}
POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=ledger.read
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImxlZGdlci1leHBvcnRlci0yMDI2LTEwIn0.eyJpc3Mi...

What the server checks (RFC 7523 §3 and OIDC Core §9): iss and sub MUST both be the client_id; aud MUST identify the authorization server, and a JWT without the server's own identity in aud MUST be rejected; exp is REQUIRED and expired assertions are rejected; OIDC Core makes jti REQUIRED and says these tokens MUST only be used once. Which audience string to use has varied historically (OIDC Core says the token endpoint URL SHOULD be used); RFC 9126 §2 resolves the ambiguity by saying the issuer identifier SHOULD be used and that servers MUST accept the issuer, the token endpoint URL or the PAR endpoint URL.

Scope and audience design

A machine client's token is often more powerful than any user's, because it is not limited by what one person may do. Design its scopes accordingly. RFC 9700 §2.3 says the privileges associated with an access token SHOULD be restricted to the minimum required, and that tokens SHOULD be audience-restricted to a specific resource server or a small set of them.

  • One client per workload. A shared "backend" client used by ten services means a leak in any of them yields the union of all their permissions, and audit logs cannot tell them apart. This is the least privilege principle applied to workloads.
  • Scopes per action, not per system. ledger.read and ledger.post rather than ledger. Avoid catch-all scopes such as admin or *.
  • Allow-list scopes per client at the authorization server, so a client cannot simply ask for more.
  • Request an audience. Use the resource parameter of RFC 8707 (or a scope that maps to one API) so each token is valid at one API only. The API MUST refuse tokens not meant for it (RFC 9700 §2.3).
  • Know what sub means. In RFC 9068 JWT access tokens, sub for a client credentials token SHOULD identify the client application, not a user (§2.2).

More on scope design is on scopes and consent.

Token caching and lifetime

The standards say little about client-side caching; the following is common practice, not a requirement. Request a token once, keep it in memory, and reuse it for every call until shortly before expires_in runs out (a margin of a minute or so absorbs clock skew and in-flight requests). Fetching a new token per API call multiplies load on the authorization server and multiplies the number of live tokens that could leak.

  • Cache per distinct (scope, audience) combination; a token for one API is not valid at another if audiences are enforced.
  • On a 401 with error="invalid_token" (RFC 6750 §3.1), drop the cached token, get a new one, and retry once.
  • Keep tokens out of logs, metrics labels, error messages and crash dumps. A logged bearer token is a usable credential.
  • Keep tokens short-lived. RFC 6750 §5.3 says token servers SHOULD issue short-lived bearer tokens (one hour or less). Because the client can always re-run the grant, short lifetimes cost little here.
  • Where the API supports it, use sender-constrained tokens (mutual TLS or DPoP) so a stolen token cannot be replayed from elsewhere. RFC 9700 §2.2.1 says authorization and resource servers SHOULD use such mechanisms.

Checklist

  • Only confidential clients, running where the credential can actually be kept secret.
  • Asymmetric client authentication (private_key_jwt or mutual TLS) instead of shared secrets where the server supports it.
  • If shared secrets are unavoidable: high-entropy, stored in a secrets manager, rotated, never in source control. See service accounts and secrets.
  • One client registration per workload, with an allow-list of scopes.
  • Audience-restricted tokens; APIs reject tokens with the wrong aud.
  • No refresh tokens for this grant.
  • Tokens cached in memory until near expiry, never logged.
  • Client registrations included in access reviews: owner, purpose, scopes, last used.

Sources

  1. RFC 6749: The OAuth 2.0 Authorization Framework, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  2. RFC 7523: JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  3. RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  4. RFC 9700: Best Current Practice for OAuth 2.0 Security, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  5. OpenID Connect Core 1.0 incorporating errata set 2, OpenID Foundation. OpenID Foundation specification (cited, brief quotations).
  6. RFC 8707: Resource Indicators for OAuth 2.0, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
  7. RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens, IETF. IETF Trust Legal Provisions (brief quotations with attribution).