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 ( 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.
-
scopestill 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 |
|---|---|---|---|
|
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. |
|
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 signs a JWT assertion with an HMAC key derived from its shared |
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 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. |
|
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 sends |
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 sends |
RFC 6749 §2.3.1 |
Equivalent to |
|
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 |
|
|
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
Authorizationheader or the full request body must redactclient_secretandAuthorization: Basicvalues — 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.
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) |
|
The client library only supports posting form parameters, or an intermediary strips |
|
A shared secret is unavoidable but the operator wants to avoid the secret itself ever appearing on the wire |
|
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.
References
-
RFC 7521 — Assertion Framework for OAuth 2.0 Client Authentication and Authorization Grants
-
RFC 7523 — JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants
-
RFC 8705 — OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
-
draft-ietf-oauth-attestation-based-client-auth— OAuth 2.0 Attestation-Based Client Authentication -
RFC 9700 / BCP 240 — Best Current Practice for OAuth 2.0 Security