Skip to content

Use case — Service-to-service (client_credentials) ​

What is the client_credentials grant? ​

Most OAuth grants involve a resource owner, a person who approves access. client_credentials (RFC 6749 §4.4) is the grant for the case with no person: service A holds a registered client_id plus a credential and exchanges them directly at /token for an access token. The token represents the service itself, not an end user, so there is no id_token, no refresh_token (asking again is cheap), and no consent prompt.

Use it for cron jobs, webhooks, microservice-to-microservice calls, and anything else with no browser and no end user.

Specs referenced on this page
  • RFC 6749 — OAuth 2.0 Authorization Framework, §4.4 (client_credentials)
  • RFC 7523 — JWT Profile for OAuth 2.0 Client Authentication (private_key_jwt)
  • RFC 8705 — OAuth 2.0 Mutual-TLS Client Authentication
  • RFC 8707 — Resource Indicators for OAuth 2.0 (pin which RS the token is for)
  • RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens
  • RFC 7662 — OAuth 2.0 Token Introspection
Vocabulary refresher
  • Confidential vs public client — A confidential client (a backend service) holds a real authentication credential (a secret or a private key). A public client (a browser SPA, a mobile app) cannot keep a secret and identifies itself by client_id alone. client_credentials is for confidential clients only: without a credential, "the client itself" has no authenticated identity.
  • private_key_jwt — Instead of sending a shared secret in the request, the client signs a short-lived JWT assertion with its private key and posts it as client_assertion. The OP verifies it against the client's pre-registered public JWKS. No secret ever crosses the wire.
  • Bearer token — An access token whose presentation alone proves authorization (RFC 6750): anyone holding it can use it. Sender constraint (DPoP / mTLS) binds the token to a key instead.

Source: examples/05-client-credentials

Architecture ​

Service to service, no user in the loop
Service A authenticates to the OP as itself and receives an access token, calls Service B with it, and Service B validates that token — either by introspection or against the published key set.Service Aconfidential clientOPgo-oidc-providerService Bresource serverPOST /tokenclient authenticationaccess_tokenAuthorization: Bearer <token>verify: /introspect | /jwks
The call from A to B never touches the OP, and B never sees A's credentials. Service B decides for itself whether to ask the OP about the token or check the signature, and that choice is what the access-token format page is about.

The flow never touches /auth.

Code ​

go
import (
  "github.com/libraz/go-oidc-provider/op"
  "github.com/libraz/go-oidc-provider/op/feature"
  "github.com/libraz/go-oidc-provider/op/grant"
  "github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)

provider, err := op.New(
  op.WithIssuer("https://op.example.com"),
  op.WithStore(inmem.New()),
  op.WithKeyset(myKeyset),
  // No browser grant is served, so cookie keys are not needed, and no
  // end user is resolved, so no user store is needed either.
  op.WithGrants(grant.ClientCredentials),
  op.WithFeature(feature.Introspect),

  op.WithStaticClients(
    op.ConfidentialClient{
      ID:         "service-a",
      Secret:     serviceASecret, // seed hashes it before storage
      AuthMethod: op.AuthClientSecretBasic,
      GrantTypes: []string{"client_credentials"},
      Scopes:     []string{"read:things", "write:things"},
      Resources:  []string{"https://api.b.example.com"}, // RFC 8707 audience pin
    },
    op.ConfidentialClient{
      ID:         "service-b",
      Secret:     serviceBSecret,
      AuthMethod: op.AuthClientSecretBasic,
      GrantTypes: []string{"client_credentials"},
    },
  ),
  op.WithProtectedResources(op.ProtectedResource{
    Resource:             "https://api.b.example.com",
    IntrospectionClients: []string{"service-b"},
  }),
)

WithGrants replaces the default grant set, so grant.ClientCredentials alone leaves authorization_code off. If you also set a browser sign-in option explicitly (WithLoginFlow, WithUI, WithInteractions, or WithFirstPartyClients) without listing grant.AuthorizationCode, op.New fails with op.ErrAuthorizationCodeGrantRequired and names the options it found. Either drop those options or add grant.AuthorizationCode to the set.

Calling the token endpoint ​

sh
curl -s -u service-a:<secret> \
  -d 'grant_type=client_credentials&scope=read:things' \
  https://op.example.com/oidc/token
# {
#   "access_token": "...",
#   "token_type": "Bearer",
#   "expires_in": 300,
#   "scope": "read:things"
# }

Confidential clients only

client_credentials is restricted to clients with a real authentication credential (client_secret_basic, client_secret_post, private_key_jwt, and with feature.MTLS tls_client_auth and self_signed_tls_client_auth). A public client (token_endpoint_auth_method=none) cannot use it. A statically seeded ConfidentialClient that uses client_secret_basic or client_secret_post must carry a Secret of at least 32 bytes or a SecretHash; op.New refuses an empty or shorter one, and op.NewClientSecret mints a matching secret and hash. Binding the token to a client certificate (cnf) is separate from authenticating with one.

Secret verification cost

The default verification of a client_secret is Argon2id, about 90 ms and 64 MiB per verification, which suits a secret a person typed. A machine-to-machine hot path with machine-generated secrets pays that on every token request. op.WithHighEntropyClientSecrets declares that every secret is machine-generated and switches verification to a keyed hash that takes microseconds. In return, op.New rejects a static client that still carries an Argon2id hash, such as one seeded with ConfidentialClient.Secret. Provision the secrets with op.NewClientSecret (or op.HashHighEntropyClientSecret for one that already exists and is at least 32 characters), and re-provision existing clients before enabling the option, since a stored record cannot be converted. 05-client-credentials notes the trade-off in its comments.

What the token contains and how requests fail ​

AspectBehaviour
subThe client_id. There is no end user, and pairwise subject projection does not apply.
scopeThe requested scopes, which must all be in the client's registered Scopes. Omitting scope yields the full registered set minus openid.
audThe resource value when the request carries one (it must be in the client's Resources, and only one value is allowed). Otherwise the issuer URL.
Responseaccess_token, token_type, expires_in, scope. Never id_token or refresh_token.
cnfPresent when the request carried a verified DPoP proof or mTLS certificate.
FailureResponse
Client not registered for client_credentials, or a public client400 unauthorized_client
openid requested, or a scope outside the client's registered set400 invalid_scope
resource malformed, unregistered for the client, or repeated with different values400 invalid_target
Missing, wrong, or replayed credentials (including a private_key_jwt assertion with an empty or over-long jti)401 invalid_client
The client's JWKS is temporarily unreachable or overloaded503 server_error
The client store is unavailable500 server_error

Production-grade: private_key_jwt instead of basic ​

For higher-assurance deployments, use private_key_jwt (RFC 7523):

go
op.WithStaticClients(op.PrivateKeyJWTClient{
  ID:         "service-a",
  JWKS:       serviceAPublicJWKs, // public JWK Set as JSON bytes
  GrantTypes: []string{"client_credentials"},
})

The PrivateKeyJWTClient seed sets token_endpoint_auth_method=private_key_jwt automatically, so this typed seed has no AuthMethod field. A client record carries either an inline jwks or a jwks_uri, never both.

Service A signs a fresh JWT assertion with its private key for each token request. The assertion needs a unique jti (the OP rejects a replay) and a lifetime of at most five minutes:

sh
curl -s -d 'grant_type=client_credentials' \
  -d 'client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
  -d "client_assertion=$JWT_ASSERTION" \
  -d 'scope=read:things' \
  https://op.example.com/oidc/token
FAPI 2.0 client_credentials

With op.WithProfile(profile.FAPI2Baseline), client_secret_basic is filtered out. The profile accepts private_key_jwt as the token endpoint client-auth method, and, with feature.MTLS, tls_client_auth and self_signed_tls_client_auth (client authentication choices). Stack feature.DPoP to additionally bind the issued access token to a client-held key.

Validating on the resource server ​

Two paths:

  1. Self-validate JWT (RFC 9068) if you configured JWT access tokens. Service B fetches /jwks, caches it, and refreshes that cache when key rotation produces an unknown kid, then verifies signatures locally.
  2. Introspect (RFC 7662) if access tokens are opaque. Service B posts the token to /introspect and reads active, scope, client_id, etc. from the JSON response. A Go resource server can let op/rs do this by setting rs.Config.Introspection (see Protect an API).
sh
curl -s -u service-b:<secret> \
  -d "token=$ACCESS_TOKEN" \
  https://op.example.com/oidc/introspect

Introspect requires a confidential caller

The introspection endpoint authenticates the caller (Service B, the resource server). Public clients and clients whose token_endpoint_auth_method is none receive 401 invalid_client; register Service B as a confidential client. By default a client can introspect only its own tokens. Put Service B in ProtectedResource.IntrospectionClients for the https://api.b.example.com audience to delegate access-token introspection to that resource server. This delegation never covers refresh tokens. See examples/05-client-credentials for the full wiring.