Skip to content

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=none and 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.
One service gets a token as itself, no user
The client credentials flow. Service A authenticates to the OP as itself, receives an access token scoped to what it registered, and presents that token as a bearer credential to Service B.Get a tokenCall the APIService Ayour backendOPgo-oidc-providerService Banother backendPOST /tokengrant_type=client_credentialsauthenticate the clientcheck the scope againstits allow-list200 · access_tokenno id_token, no refresh_tokenGET /thingsAuthorization: Bearer <token>200 [ … ]
The access token is the only thing that comes back: no ID token, because there is no user to describe, and no refresh token, because the client can authenticate and ask again. The 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.

go
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.

go
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 in grant_types_supported of the discovery document.
  • Per-client (GrantTypes on the client record) sets which of those grants this client may use. When left empty, a static client defaults to authorization_code and refresh_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 issuer
  • aud — the resource the request named (RFC 8707), or the OP issuer when it named none; see below
  • client_id — the requesting client
  • sub = client_id — RFC 9068 §2.2 says sub SHOULD correspond to the client application; this library uses the client itself as the subject
  • scope — the granted scopes. A request without scope gets every scope registered on the client except openid

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), aud is that value, canonicalised. The value must appear in the client's registered Resources allow-list, and only one resource is accepted per request; anything else is 400 invalid_target.
  • Without resource, aud is 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 ​

ConditionResponse
Client authentication failsinvalid_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 GrantTypes400 unauthorized_client
scope contains openid400 invalid_scope
scope contains an entry outside the client's registered Scopes400 invalid_scope (rejected, not silently narrowed)
resource malformed, repeated with different values, or not in Resources400 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.

sh
(cd examples/05-client-credentials && GOWORK=off go run -tags example .)