Client Credentials (client_credentials)
This page explains how a backend service, with no user involved, uses its own credentials to obtain an access token from wicket, and how the API being called validates that token. The requests and responses on this page come from actually running the Quick Start example program (examples/quickstart) on v0.1.4. The findings about secret Expiration come from an experimental program that added a test client to the same setup.
When to use it
- Scheduled jobs, message consumers, and calls between internal microservices;
- The token represents the client itself: it has no
sub, and noid_tokenorrefresh_tokenis issued.
When you need to access resources on behalf of a user, use authorization code + PKCE or CIBA instead.
1. Define the client and resources
The following snippet is taken from the Quick Start example. For the full setup (signing keys, hosting.ScopeMiddleware, etc.), see Quick Start:
// API scope and API resource
api := models.NewApiScope()
api.Name = "orders.read"
apiSecret := models.NewSecret()
apiSecret.Value = utils.Sha256("api-secret") // the resource's own secret, used for introspection
orders := models.NewApiResource()
orders.Name = "orders"
orders.Scopes = []string{"orders.read"}
orders.ApiSecrets = []*models.Secret{apiSecret}
// Machine client
secret := models.NewSecret()
secret.Value = utils.Sha256("change-me")
client := models.NewClient()
client.ClientId = "billing-worker"
client.ClientSecrets = []*models.Secret{secret}
client.AllowedScopes = []string{"orders.read"}
if err := client.SetAllowedGrantTypes(wicket.GrantTypesClientCredentials); err != nil {
log.Fatal(err)
}
cfg := root.ServerConfig()
cfg.SetApiScopes([]*models.ApiScope{api})
cfg.SetApiResources([]*models.ApiResource{orders})
cfg.SetClients([]*models.Client{client})Key points:
- Store secrets as hashes.
Secret.Valueholdsutils.Sha256(plaintext)(SHA-256, then Base64); the client sends the plaintext in requests. Never put the plaintext in configuration or the database. - A client can have multiple secrets at once; a match on any of them passes. To rotate a secret, add the new one first, then remove the old one after the client has switched over.
- Grant types are set through
SetAllowedGrantTypes(the field itself cannot be assigned directly). It rejects duplicates and values containing spaces.wicket.GrantTypesClientCredentialsand the like are predefined combinations. NewClient()defaults:RequireClientSecret = true,AccessTokenLifetime = 3600seconds, andAccessTokenTypeis JWT.
2. Request a token
The token endpoint is POST /connect/token, form-encoded:
curl -s -u billing-worker:change-me \
-d grant_type=client_credentials \
-d scope=orders.read \
http://localhost:5000/connect/tokenActual response (token truncated):
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InVBdm9RSHVz...",
"expires_in": 3600,
"id_token": "",
"refresh_token": "",
"scope": "orders.read",
"token_type": "Bearer"
}Empty fields in the response
In v0.1.4, token responses always include the id_token and refresh_token keys; when not issued, their values are empty strings. Clients should treat an empty value as absent, rather than only checking whether the key exists.
If you omit the scope parameter, wicket issues all available scopes from the client's AllowedScopes (in this example the result is also orders.read).
3. What's in the token
The access token is a JWT. The decoded header and payload (actual output):
{ "alg": "RS256", "kid": "uAvoQHusrZOFYuv6MBTpWlh6Av1iJlt38tmDkQ6-d0s", "typ": "at+jwt" }{
"aud": ["orders"],
"client_id": "billing-worker",
"exp": 1794542360,
"iat": 1794538760,
"iss": "http://localhost:5000",
"nbf": 1794538760,
"scope": ["orders.read"]
}typisat+jwt, which lets resource servers distinguish access tokens fromid_tokens.audcomes from the name of the API resource the scope belongs to (orders), not the scope name.- In the JWT,
scopeis an array; in the introspection response it is a space-separated string (see below). kidmatches the public key published at/.well-known/openid-configuration/jwks, so resource servers can validate signatures offline. See Local signature validation and server-side exchange.
4. Client authentication methods
The discovery document's token_endpoint_auth_methods_supported lists client_secret_basic and client_secret_post; both are handled by the built-in secret parser.
| Method | How | Notes |
|---|---|---|
client_secret_basic | Authorization: Basic base64(client_id:client_secret), i.e. curl -u | Recommended; credentials stay out of the form body and access logs |
client_secret_post | Include client_id and client_secret in the form | For environments where you cannot set request headers |
Equivalent request using client_secret_post (actually returns 200 and an access token):
curl -s \
-d client_id=billing-worker \
-d client_secret=change-me \
-d grant_type=client_credentials \
http://localhost:5000/connect/tokenA request with only client_id and no secret gets invalid_client, because this client's RequireClientSecret is true.
v0.1.4: do not set Expiration on secrets
v0.1.4 handles secret Expiration incorrectly: a secret with Expiration set may be wrongly rejected (returning invalid_client). Until a fixed version is released, keep Expiration at its zero value and rotate by adding and removing secrets. Delete retired secrets from the configuration directly; do not rely on Expiration to disable them.
5. Validate tokens in the API with introspection
If you don't want to validate JWT signatures in the API, you can call the introspection endpoint POST /connect/introspect. The caller is the API resource, not the client: it authenticates with Basic auth using ApiResource.Name as the username and the plaintext of a secret in ApiSecrets as the password. A resource without ApiSecrets cannot call the introspection endpoint.
curl -s -u orders:api-secret \
-d token="$ACCESS_TOKEN" \
http://localhost:5000/connect/introspectActual response:
{
"active": true,
"aud": ["orders"],
"client_id": "billing-worker",
"exp": 1794542360,
"iat": 1794538760,
"iss": "http://localhost:5000",
"nbf": 1794538760,
"scope": "orders.read"
}Other cases (all actual results):
| Request | Result |
|---|---|
token is an unrecognized string | 200, {"active":false} |
Wrong API resource secret (-u orders:wrong) | 401, empty body |
Called with client credentials (-u billing-worker:change-me) | 401, empty body |
Introspection is not authorization
active: true only means the token is valid. The API must still check whether scope contains the permission the current endpoint requires.
6. Common errors
All of the following are actual results. Note that in v0.1.4, error responses always use status 400 (including invalid_client), the Content-Type is text/plain; charset=utf-8, and the body is JSON with a custom field:
{"custom":{},"error":"invalid_client","error_description":""}error | Cause | What to check |
|---|---|---|
invalid_client | Client does not exist, wrong secret, or a secret is required but missing | Confirm that Secret.Value holds utils.Sha256(plaintext) and the request sends the plaintext; confirm no Expiration is set on the secret (see the warning above) |
invalid_scope | Requested a scope outside the client's AllowedScopes or not defined on the server (in testing, both orders.write and openid return this error) | Check AllowedScopes and the registered ApiScopes; the client credentials flow has no user, so do not request identity scopes such as openid |
unauthorized_client | The client is not allowed to use this grant_type | Check SetAllowedGrantTypes |
invalid_request | Used GET on the token endpoint, missing a required parameter, or control characters in the form | The token endpoint accepts only POST forms |
Compatible client code
The standard recommends 401 for invalid_client. If your client library only treats 401 as a credentials error, also check the error field in the response body.
Related reading
- Quick Start: full setup code for the example on this page
- Clients and resources:
Client,ApiResource, andApiScopefield reference - Signing keys: how to provide signing credentials in production
- Protocol endpoints: parameters for the token and introspection endpoints