ndovuoauth2

FLOW DEEP DIVE · RFC 6749 §4.4

The client-credentials flow

OAuth2 machine-to-machine: the client-credentials grant on the wire, the client authentication methods that decide whether it is secure, token caching, and how it fails in production.

October 26, 2026confidential clients onlyThe framework first
01 · What it is and when it reaches for it

The simplest grant in the framework, and the only one with no human in it: a service authenticates as itself and gets a token for its own scopes. Cron jobs hitting your API, microservice-to-microservice calls, CI pipelines, daemons, batch jobs - anywhere the client and the resource owner are the same party. Section 4.4 is direct about eligibility: "the client credentials grant type MUST only be used by confidential clients". A public client has nothing to authenticate with; a machine flow built on one is role-play.

Because "the client authentication is used as the authorization grant" (4.4.1), there is no authorization endpoint, no browser, no consent screen. The whole grant is one POST. That economy is also the security story: the token is exactly as strong as the client authentication behind it - which is why this page spends more time on authentication methods than on the flow.

02 · The flow

Two hops before the API call. Everything is back channel.

Client credentials flow: service, authorization server, and API, all direct TLS callsService (client)Auth serverAPIdaemon, CI, backendresource server1. POST /token(client auth + grant_type=client_credentials)2. access_token(no refresh token expected)3. Authorization: Bearer ...4. protected resourceno authorization endpoint, no browser, no user - the client acts as itself

All arrows are back channel (direct, over TLS). There is no front channel in this flow - nothing ever crosses a browser.

03 · The wire
The token request (4.4.2)
POST /token HTTP/1.1
Host: accounts.example.com
Authorization: Basic PGNsaWVudC1pZD46PGNsaWVudC1zZWNyZXQ+  (base64 of the words client-id and client-secret)

grant_type=client_credentials
scope=invoices:issue

Two parameters, one of them optional. grant_type is REQUIRED and MUST be client_credentials; scope is OPTIONAL (4.4.2). Client authentication is a MUST on both sides - "the client MUST authenticate with the authorization server", "the authorization server MUST authenticate the client". No redirect_uri, no code, no verifier: those exist to carry a user's grant, and there is no user here.

The token response (4.4.3)
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache

{
  "access_token": "eyJhbGciOiJQUzI1NiIsImtpZCI6IjcxIn0.<payload>.<signature>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "invoices:issue"
}

Note what is missing: "a refresh token SHOULD NOT be included" (4.4.3). There is nothing to keep alive - when the token expires, the service simply authenticates again, which costs it nothing. A refresh token would add a second long-lived credential to protect for zero benefit.

Failures answer in the framework's shared grammar: a rejected secret, certificate, or assertion comes back as invalid_client(a 401 when the client used the Authorization header); a client not registered for this grant type gets unauthorized_client; overscoped requests get invalid_scope. What each code means, with typical causes: theerror-code table on the framework page.

04 · The client authentication methods

The grant is only as strong as its client authentication, and the options are not equivalent. A shared secret is a string your operations pipeline leaks; asymmetric methods put the proof in something that never leaves the client. RFC 9700 §2.5 settles the direction: "it is RECOMMENDED to use asymmetric cryptography for client authentication, such as mutual TLS for OAuth 2.0 [RFC 8705] or signed JWTs ('Private Key JWT')".

MethodCredentialWhereNotes
client_secret_basicShared secretHTTP Basic headerThe 6749 MUST-support baseline (2.3.1). Gotcha: id and secret are form-urlencoded before becoming Basic username/password. OAuth 2.1 demotes Basic to MAY.
client_secret_postShared secretBody parametersNOT RECOMMENDED in 6749 (2.3.1), "SHOULD be limited to clients unable to directly utilize the HTTP Basic authentication scheme"; body only, "MUST NOT be included in the request URI". OAuth 2.1 flips it to the MUST-support method.
private_key_jwtRegistered public key + signed JWT assertionBody client_assertionPer RFC 7523; the secret never exists server-side. The assertion MUST carry a short exp (RFC 7523 §3); replay protection via a jti claim is optional - rotation and revocation are key operations, not secret scrambles.
tls_client_authClient certificatemTLS handshakeRFC 8705 §2. The certificate IS the credential; also enables certificate-bound tokens (§3) so the access token is useless without the private key.

Whichever you pick, remember what section 3.2.1 says the authentication is for: "enforcing the binding of refresh tokens and authorization codes to the client they were issued to", breach recovery, and credential rotation. A secret hardcoded into a container image answers none of those.

05 · Living with the token

Cache it. The classic production mistake is requesting a new token per API call: your authorization server becomes a hot dependency of every request. Hold the token until just before expires_in runs out (with a safety margin for clock skew) and reuse it across calls and threads. The grant is cheap precisely so you can re-run it when you must.

Read it correctly. If the token comes back as a JWT (theRFC 9068 profile), its sub should be the client's identifier, not a user's (RFC 9068 §2.2, a SHOULD the major servers follow) - machine tokens routinely break user-centric authorization code for exactly this reason. Check aud before accepting the token across service boundaries; the audience rule exists to stop one service accepting a token minted for another (RFC 9700 §2.3).

Scope it. Request the narrowest scope the job needs - section 10.3's "minimal scope necessary" advice applies double when the token's lifetime is measured in cron schedules rather than browser sessions.

06 · How it goes wrong
FailureWhat happensThe fix
Secret leakageShared secrets ride CI logs, env dumps, and container images; any reader now authenticates as the serviceAsymmetric methods: private_key_jwt or mTLS - the proof never leaves the client (RFC 9700 §2.5)
Per-request token fetchEvery API call pays a token-endpoint round trip; the AS falls over firstCache until near-expiry with clock-skew margin
Treated as a userAuthorization code keyed on user identity breaks: a machine token's sub should be the client (RFC 9068 §2.2, SHOULD)Model machine identities as first-class principals
No audience disciplineA token minted for service A is accepted by service B - one leak becomes lateral movement (RFC 9700 §2.3, §4.9)Audience-restricted tokens; resource servers verify aud on every request
Public-client machine flowsA device app "authenticates" with an extractable secret and calls itself confidentialThe grant is confidential-only by definition (6749 §4.4); rethink who the client is
07 · Primary sources