Client Credentials
grant_type=client_credentials is the service-to-service grant: no end user, no browser, no consent. A confidential backend authenticates as itself at the token endpoint and gets an access token to call another backend. Use it for batch jobs, internal APIs and daemons; use authorization code + PKCE whenever a person is involved.
Specs referenced on this page
- RFC 6749 — OAuth 2.0 Authorization Framework (§4.4 client credentials)
- RFC 7521 — Assertion Framework for OAuth 2.0
- RFC 7523 — JWT Profile for OAuth 2.0 Client Authentication (
private_key_jwt) - RFC 7800 — Confirmation (
cnf) claim - RFC 8705 — Mutual-TLS Client Authentication
- RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens
Vocabulary refresher
- Confidential client — a client that can hold a real authentication credential (a long-lived secret, an asymmetric key, or an X.509 certificate). Backends and trusted servers are confidential. See Client types.
- Public client — a client that cannot hold a real secret in practice (SPAs in a browser, native mobile apps). They authenticate with
token_endpoint_auth_method=noneand rely on PKCE for code protection. private_key_jwt— client authentication where the client signs a short-lived JWT with its private key; the OP verifies it against the registered public key. Stronger than a shared secret because the secret never leaves the client.
sub of the token is the client itself.Confidential clients only
client_credentials is restricted to confidential clients — clients with a real authentication credential (client_secret_basic, client_secret_post, private_key_jwt, and, with feature.MTLS, tls_client_auth or self_signed_tls_client_auth). Public clients (SPAs / native, token_endpoint_auth_method=none) cannot use it. mTLS also binds the issued access token to the client certificate, whichever method authenticated the client.
There is no end user, so there is no PKCE, no consent, no id_token, and no refresh token. The client just re-runs the grant when the access token expires.
Wiring
Enable the grant explicitly. The token endpoint refuses any grant type that is not in the enabled set.
import (
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/grant"
"github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)
st := inmem.New()
browserFlow := op.LoginFlow{Primary: op.PrimaryPassword{Store: st.UserPasswords()}}
handler, err := op.New(
op.WithIssuer("https://op.example.com"),
op.WithStore(st),
op.WithKeyset(myKeyset),
op.WithCookieKeys(cookieKey),
op.WithLoginFlow(browserFlow),
op.WithGrants(
grant.AuthorizationCode, // for human users
grant.RefreshToken,
grant.ClientCredentials, // for service-to-service
),
op.WithStaticClients(/* a confidential client */),
)A registered client opts in through GrantTypes. A static client_secret_basic / client_secret_post client must carry Secret (or a pre-computed SecretHash); op.New refuses one without, and refuses a plaintext Secret shorter than 32 bytes (op.NewClientSecret returns a secret and its hash). For private_key_jwt, use op.PrivateKeyJWTClient with the public JWKS.
op.WithStaticClients(op.ConfidentialClient{
ID: "service-a",
Secret: "service-a-secret-at-least-32-bytes-long", // 32 bytes or more
AuthMethod: op.AuthClientSecretBasic, // the default when empty
GrantTypes: []string{"client_credentials"},
Scopes: []string{"read:things", "write:things"},
})Why both global and per-client opt-in?
- Global (
op.WithGrants) sets which grants the OP supports at all, surfaced ingrant_types_supportedof the discovery document. - Per-client (
GrantTypeson the client record) sets which of those grants this client may use. When left empty, a static client defaults toauthorization_codeandrefresh_token; a non-empty list replaces the default entirely.
A request succeeds only when both checks pass. One OP can therefore issue authorization_code to end-user apps and client_credentials to backend services, and an SPA client cannot mint app-level tokens.
Token shape
client_credentials access tokens carry:
iss— the OP issueraud— theresourcethe request named (RFC 8707), or the OP issuer when it named none; see belowclient_id— the requesting clientsub = client_id— RFC 9068 §2.2 sayssubSHOULD correspond to the client application; this library uses the client itself as the subjectscope— the granted scopes. A request withoutscopegets every scope registered on the client exceptopenid
When op.WithFeature(feature.MTLS) or DPoP is configured and the client presented a sender constraint, the token additionally carries the cnf (Confirmation, RFC 7800) claim binding it to that constraint.
The subject is never pairwise: with a pairwise SubjectType on the client, sub is still the plain client_id, because pairwise identifiers are defined for end users only.
Why sub = client_id here?
In the user-facing flows, sub is the end-user's stable identifier. In client_credentials there is no end user, so RFC 9068 §2.2 says sub SHOULD correspond to the client application; it does not require literal equality with client_id. This library chooses that direct mapping and sets sub = client_id, so the client itself acts as the subject. RSes that read sub to log "who did this" still get a stable identifier; they just need to know that for service tokens, that identifier is a registered service rather than a person. A common bug is to assume sub always points to a row in the user table; for service tokens it points to a row in the client table.
aud for client_credentials — what it means and how it's set
aud names the resource server the token is for — not the client_id. It tells the RS "this token was minted for me to consume." The library sets it from the request:
- With
resource=https://api.example.com(RFC 8707),audis that value, canonicalised. The value must appear in the client's registeredResourcesallow-list, and only oneresourceis accepted per request; anything else is400 invalid_target. - Without
resource,audis the OP issuer.
Resources is an allow-list, not a default: registering it does not put anything into aud by itself. An RS should reject tokens whose aud is not its own identifier. That is the audience-restriction defence against a token stolen from one RS being replayed against another.
What the token endpoint answers
| Condition | Response |
|---|---|
| Client authentication fails | invalid_client; 503 instead when the client's JWKS fetch is overloaded or failing, 500 server_error on a client-store outage |
Public client, or client_credentials not in the client's GrantTypes | 400 unauthorized_client |
scope contains openid | 400 invalid_scope |
scope contains an entry outside the client's registered Scopes | 400 invalid_scope (rejected, not silently narrowed) |
resource malformed, repeated with different values, or not in Resources | 400 invalid_target |
Public and unregistered-grant clients get the same code on purpose, so a caller cannot tell the two apart from the wire.
Bearer vs sender-constrained — what's the difference?
By default, client_credentials access tokens are bearer tokens (RFC 6750): "whoever holds it, can use it." A leaked bearer token works for the attacker until it expires.
Sender-constrained access tokens (RFC 8705 mTLS or RFC 9449 DPoP) carry a cnf claim binding the token to a key the legitimate client holds. The RS verifies, on every API call, that the caller proves possession of that key. A leaked token is then useless without the matching private key — and the matching private key never leaves the client.
For service-to-service traffic in regulated contexts (FAPI 2.0, PSD2, OBL), sender-constrained is the expected default. For internal-network service traffic where mTLS is already enforced at the mesh, plain bearer is often acceptable.
See it run
examples/05-client-credentials is a machine-to-machine-only OP (op.WithGrants(grant.ClientCredentials)) with one client_secret_basic client; the curl command it prints at startup fetches a token.
(cd examples/05-client-credentials && GOWORK=off go run -tags example .)Read next
- Access token format — JWT vs opaque, and how
audselects the format. - Sender constraint (DPoP / mTLS) — bind service tokens to a client-held key so a stolen token is useless.
- Use case: client_credentials in production.