Client Credentials and Client Authentication

This section documents OAuth 2.0 (RFC 6749) as amended by the OAuth 2.0 Security Best Current Practice (RFC 9700 / BCP 240), the OAuth 1.0 Protocol (RFC 5849) for historical context, and OpenID Connect Core 1.0, as published at the IETF Datatracker and the OpenID Foundation specifications — and, on the Spring pages, Spring Boot 4.1.x and Spring Security 7.1.x as published at the Spring Security reference documentation — which are the references these pages are written and verified against.

OAuth 2.1 is still an Internet-Draft (draft-ietf-oauth-v2-1-16, 3 September 2026) and is flagged as such everywhere it appears on these pages. It is a working-group consolidation in progress, not a published standard; nothing here should be read as saying otherwise.

This content was generated with the assistance of AI and should be verified against those official specifications before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

The client credentials grant (RFC 6749 §4.4) is the machine-to-machine case: a client acting purely as itself, with no resource owner and no browser anywhere in the exchange. This page covers that wire exchange, then works through every way a client can prove its own identity to the token endpoint — because how the client authenticates is a bigger design decision here than which grant it uses, and it applies equally to the authorization code and refresh-token grants once a client has any credential to present at all.

Machine-to-machine with no user

There is no resource owner in this flow, which changes several things the authorization code grant takes for granted:

  • No refresh token. A refresh token exists to let a client obtain new access tokens without re-involving the resource owner. There is no resource owner to re-involve here — the client simply requests a new access token with the same grant when the old one expires, authenticating itself again in the process.

  • No consent screen. Consent exists to let the resource owner see and approve what a client is being granted on their behalf. A service acting as itself is not acting on anyone’s behalf, so there is nothing to consent to; whatever access the client has was decided when it was registered and authorized, not at request time.

  • scope still applies, but it names capabilities of the service account the client is, not permissions delegated from a person — see Scopes, Claims and Permissions for how to keep that distinction visible in scope naming.

The wire exchange

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

grant_type=client_credentials
&scope=orders.write%20inventory.read
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "orders.write inventory.read"
}

No redirect_uri, no code, no id_token — only the client authenticating itself, the scope it wants, and the token it gets back. Error responses reuse the same codes as any other grant at this endpoint: invalid_client (authentication failed), invalid_scope (scope exceeds what this client account may hold), and unauthorized_client (this client is not permitted to use client_credentials at all).

Access-token lifetimes for client-credentials tokens are usually shorter than user-delegated ones (minutes, not hours) precisely because there is no refresh token to rotate — the client is expected to simply request a new token on expiry, so there is little cost to a short lifetime and real benefit in limiting the exposure window of a leaked one.

Client authentication methods compared

Every method below answers the same question — "prove you are the client you claim to be" — at the token endpoint, whether the grant being requested is client_credentials, authorization_code, or refresh_token. They are listed in the recommendation order this page uses: strongest / most operationally sound first.

Method How it proves identity Defining spec Recommendation

private_key_jwt

Client signs a JWT assertion with its own private key; the authorization server verifies it against the client’s registered public key (JWKS). No secret ever crosses the wire.

RFC 7523 (JWT Profile for Client Authentication)

Recommended default for confidential clients that can manage a key pair — nothing to leak in transit, and key rotation does not require issuing a new shared secret.

tls_client_auth / self_signed_tls_client_auth

The client’s mutual-TLS certificate, presented during the TLS handshake itself, is matched against a registered certificate (subject DN or SAN) or a registered public key.

RFC 8705 (OAuth 2.0 Mutual-TLS Client Authentication)

Recommended wherever a PKI or mesh already issues client certificates — authentication happens at the transport layer, before the HTTP request is even parsed, and tokens can additionally be certificate-bound (see Sender-Constrained Tokens, DPoP and mTLS).

client_secret_jwt

Client signs a JWT assertion with an HMAC key derived from its shared client_secret (HS256 and friends). Nothing is sent in the clear beyond the signed assertion, but the underlying secret is still a shared symmetric value.

RFC 7523

Acceptable where asymmetric keys or mTLS are not feasible but a plain shared secret in the body or header is considered too weak; strictly better than client_secret_basic/client_secret_post since the secret itself is never transmitted.

draft-ietf-oauth-attestation-based-client-auth

Client presents a hardware- or platform-issued attestation (e.g. from a mobile device’s secure enclave or a confidential-computing environment) proving software and environment integrity, rather than a possession-based secret.

draft-ietf-oauth-attestation-based-client-auth (Internet-Draft)

Emerging — worth tracking for native-app and highly regulated deployments where device integrity, not just key possession, needs to be proven; not yet a stable, widely deployed standard.

client_secret_basic

Client sends client_id / client_secret as HTTP Basic credentials in the Authorization header.

RFC 6749 §2.3.1

Common baseline for confidential clients with no PKI and no key-management story; acceptable over TLS, but the shared secret must be stored, rotated, and never logged — see Secret rotation and storage.

client_secret_post

Client sends client_id / client_secret as form parameters in the request body instead of the Authorization header.

RFC 6749 §2.3.1

Equivalent to client_secret_basic in security properties; prefer Basic where the client library supports it cleanly, since request bodies are more likely to be logged in full by intermediaries than headers with credential-scrubbing rules.

none

No authentication at all — used only by public clients, which by definition cannot hold a secret confidentially (SPAs, native apps, CLIs).

RFC 6749 §2.3

Required, not optional, for public clients — there is nothing to authenticate with, so the security of the exchange rests entirely on PKCE and exact redirect-URI matching instead. Never assign none to a confidential client simply to skip implementing authentication.

draft-ietf-oauth-rfc7523bis, the planned revision of RFC 7523, is currently in the RFC Editor queue — it clarifies and tightens the JWT-assertion profile that both private_key_jwt and client_secret_jwt build on, without changing the wire format described here. Treat it as forthcoming clarification, not as introducing a new method.

private_key_jwt on the wire

Because it is the recommended default, it is worth seeing once at the wire level. The client builds and signs a JWT asserting itself as both issuer and subject, then sends it instead of a shared secret:

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

grant_type=client_credentials
&scope=orders.write
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0xIn0.eyJpc3MiOiJzNkJoZFJrcXQzIiwic3ViIjoiczZCaGRSa3F0MyIsImF1ZCI6Imh0dHBzOi8vYXMuZXhhbXBsZS5jb20vdG9rZW4iLCJqdGkiOiJhLTEyOCIsImV4cCI6MTc5MjAwMDYwMH0.signature

Decoded, the assertion’s claims are iss and sub (both the client’s own client_id), aud (the token endpoint URL), jti (a unique value, checked against replay), and a short exp. The authorization server verifies the signature against the client’s registered public key (typically published at a JWKS URI the client registered) — see JWT and JOSE for how JWT signature validation and JWKS key rollover work in general.

Secret rotation and storage

Whichever shared-secret method a client uses, the secret needs a lifecycle:

  • Never commit it to source control or bake it into a container image layer — inject it at runtime from a secrets manager or an orchestrator-native secret store.

  • Rotate on a schedule, not only on suspected compromise. Support dual validity during rotation: the authorization server should accept both the old and the new secret for a short overlap window so a rolling deployment does not experience an authentication outage mid-rollout.

  • Never log it. Request logging that captures the Authorization header or the full request body must redact client_secret and Authorization: Basic values — this is one of the concrete leakage paths RFC 9700 calls out as a real-world source of compromised credentials.

  • Scope the blast radius: one secret per environment (never share a production secret with staging), and where the deployment topology allows it, one secret per service instance rather than one shared across a whole fleet, so a single leaked instance does not compromise every caller.

When a service mesh / workload identity is the better answer

Managing a shared secret at all is itself the thing to avoid where a workload identity system is already present. In a service mesh or platform that issues short-lived, automatically rotated identity certificates to every workload (a SPIFFE/SPIRE deployment, or a cloud platform’s native workload-identity federation), it usually makes more sense to have the authorization server accept tls_client_auth against those platform-issued certificates than to provision and rotate a separate OAuth client secret in parallel. This collapses two credential lifecycles (the mesh’s own mTLS identity and the OAuth client secret) into one, and it means a compromised or decommissioned workload loses both its mesh identity and its OAuth authentication in a single revocation action rather than two that can drift out of sync. Reach for this whenever the calling services already live inside such a mesh; reach for private_key_jwt when they do not, since it gets most of the same "nothing shared crosses the wire" property without requiring a mesh to exist at all.

sequenceDiagram participant C as Client (service) participant AS as Authorization server participant RS as Resource server C->>C: build client_assertion (private_key_jwt)\nor present mTLS certificate C->>AS: POST /token\ngrant_type=client_credentials, scope,\nclient_assertion or mTLS handshake AS->>AS: verify signature against registered\npublic key / certificate, check jti + exp AS->>C: 200 OK\naccess_token, token_type, expires_in, scope C->>RS: GET /orders\nAuthorization: Bearer access_token RS->>RS: validate access token\n(JWT locally or via introspection) RS->>C: 200 OK\nresponse body

tls_client_auth on the wire

Mutual TLS moves client authentication out of the HTTP request entirely and into the TLS handshake, so the /token request itself carries no credential-shaped parameter at all:

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

grant_type=client_credentials
&client_id=1b4f3c00-3baa
&scope=orders.write

The authorization server terminates this connection only after validating the client’s certificate presented during the TLS handshake — either against a certificate authority it trusts (tls_client_auth, matching a registered subject DN or SAN) or against a specific self-signed public key it has on file for this client (self_signed_tls_client_auth). client_id is still required in the request body so the authorization server knows which registered client’s certificate binding to check against. A resource server can additionally require the same certificate be presented when the resulting access token is redeemed — a certificate-bound access token — which is covered together with DPoP on Sender-Constrained Tokens, DPoP and mTLS.

Choosing among the shared-secret methods

Where a key pair or an existing mTLS identity is not available and a shared secret is the realistic option, the choice between client_secret_basic, client_secret_post, and client_secret_jwt mostly comes down to what the client library supports cleanly and how the request path is logged:

If…​ Prefer

The client library has first-class support for HTTP Basic auth and the operator controls request logging (headers redacted)

client_secret_basic

The client library only supports posting form parameters, or an intermediary strips Authorization headers

client_secret_post, with extra care that access logs do not capture request bodies unredacted

A shared secret is unavoidable but the operator wants to avoid the secret itself ever appearing on the wire

client_secret_jwt — the secret only ever signs a short-lived assertion locally

A minimal Java client-credentials client

Wire-level examples above show what any HTTP client must produce; a plain java.net.http.HttpClient call issuing the exact client_secret_basic request from Machine-to-machine with no user looks like this (no Spring dependency — the Spring-specific OAuth2AuthorizedClientManager recipe lives on Spring Authorization Server & Social Login and is not repeated here):

String credentials = Base64.getEncoder()
        .encodeToString("s6BhdRkqt3:goTFhplQLc".getBytes(StandardCharsets.UTF_8));

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://as.example.com/token"))
        .header("Authorization", "Basic " + credentials)
        .header("Content-Type", "application/x-www-form-urlencoded")
        .POST(HttpRequest.BodyPublishers.ofString(
                "grant_type=client_credentials&scope=orders.write%20inventory.read"))
        .build();

HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());

The response is the plain JSON token response shown earlier — decode it, cache the access_token for expires_in seconds minus a safety margin, and request a fresh one on expiry rather than trying to refresh it, since this grant issues no refresh_token.