Skip to content

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 no id_token or refresh_token is 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:

go
// 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.Value holds utils.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.GrantTypesClientCredentials and the like are predefined combinations.
  • NewClient() defaults: RequireClientSecret = true, AccessTokenLifetime = 3600 seconds, and AccessTokenType is JWT.

2. Request a token ​

The token endpoint is POST /connect/token, form-encoded:

bash
curl -s -u billing-worker:change-me \
  -d grant_type=client_credentials \
  -d scope=orders.read \
  http://localhost:5000/connect/token

Actual response (token truncated):

json
{
  "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):

json
{ "alg": "RS256", "kid": "uAvoQHusrZOFYuv6MBTpWlh6Av1iJlt38tmDkQ6-d0s", "typ": "at+jwt" }
json
{
  "aud": ["orders"],
  "client_id": "billing-worker",
  "exp": 1794542360,
  "iat": 1794538760,
  "iss": "http://localhost:5000",
  "nbf": 1794538760,
  "scope": ["orders.read"]
}
  • typ is at+jwt, which lets resource servers distinguish access tokens from id_tokens.
  • aud comes from the name of the API resource the scope belongs to (orders), not the scope name.
  • In the JWT, scope is an array; in the introspection response it is a space-separated string (see below).
  • kid matches 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.

MethodHowNotes
client_secret_basicAuthorization: Basic base64(client_id:client_secret), i.e. curl -uRecommended; credentials stay out of the form body and access logs
client_secret_postInclude client_id and client_secret in the formFor environments where you cannot set request headers

Equivalent request using client_secret_post (actually returns 200 and an access token):

bash
curl -s \
  -d client_id=billing-worker \
  -d client_secret=change-me \
  -d grant_type=client_credentials \
  http://localhost:5000/connect/token

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

bash
curl -s -u orders:api-secret \
  -d token="$ACCESS_TOKEN" \
  http://localhost:5000/connect/introspect

Actual response:

json
{
  "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):

RequestResult
token is an unrecognized string200, {"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:

json
{"custom":{},"error":"invalid_client","error_description":""}
errorCauseWhat to check
invalid_clientClient does not exist, wrong secret, or a secret is required but missingConfirm 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_scopeRequested 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_clientThe client is not allowed to use this grant_typeCheck SetAllowedGrantTypes
invalid_requestUsed GET on the token endpoint, missing a required parameter, or control characters in the formThe 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.

Released under the Apache License 2.0 · Documentation © 2026 Wicket authors