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_idalone.client_credentialsis 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 asclient_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
The flow never touches /auth.
Code
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
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
| Aspect | Behaviour |
|---|---|
sub | The client_id. There is no end user, and pairwise subject projection does not apply. |
scope | The requested scopes, which must all be in the client's registered Scopes. Omitting scope yields the full registered set minus openid. |
aud | The 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. |
| Response | access_token, token_type, expires_in, scope. Never id_token or refresh_token. |
cnf | Present when the request carried a verified DPoP proof or mTLS certificate. |
| Failure | Response |
|---|---|
Client not registered for client_credentials, or a public client | 400 unauthorized_client |
openid requested, or a scope outside the client's registered set | 400 invalid_scope |
resource malformed, unregistered for the client, or repeated with different values | 400 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 overloaded | 503 server_error |
| The client store is unavailable | 500 server_error |
Production-grade: private_key_jwt instead of basic
For higher-assurance deployments, use private_key_jwt (RFC 7523):
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:
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/tokenFAPI 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:
- 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 unknownkid, then verifies signatures locally. - Introspect (RFC 7662) if access tokens are opaque. Service B posts the token to
/introspectand readsactive,scope,client_id, etc. from the JSON response. A Go resource server can letop/rsdo this by settingrs.Config.Introspection(see Protect an API).
curl -s -u service-b:<secret> \
-d "token=$ACCESS_TOKEN" \
https://op.example.com/oidc/introspectIntrospect 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.