# How to get a client credentials token for calls between services

> Get a machine token with grant_type=client_credentials, name the API by audience or scope, cache it until it nears expiry, and prove it by counting token requests.

Source: https://voxgig.com/howto/client-credentials-for-service-to-service-calls

- Audience: platform-team
- Level: beginner
- Languages: go, python
- Verified: 2026-10-20
- Published: 2026-10-17

## Short answer

Post `grant_type=client_credentials` to the token endpoint with the client id and secret in an HTTP Basic header, and name the API you want with `audience` on Auth0 or a resource server scope on Cognito. Keep the token until `expires_in` is nearly up and renew it then, through `golang.org/x/oauth2/clientcredentials` in Go or Authlib's session in Python. A run of API calls then costs one token request, however many calls it makes.

---
## You will need

Go 1.24 or later and Python 3.13 or later. A machine client registered on your authorization
server: a client id, a client secret, and either an audience or a scope that names the API.
Verified 2026-10-20 against Go 1.24.7, golang.org/x/oauth2 0.35.0, Python 3.13.16, Authlib 1.9.1,
and requests 2.34.2. Each sample runs against a stand-in authorization server in its own process, which issues signed
tokens, counts token requests, and refuses them past a limit. The hosted providers were read, not
called. The grant is [RFC 6749 section 4.4](https://www.rfc-editor.org/rfc/rfc6749#section-4.4),
for confidential clients only.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Amazon Cognito](https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html) | A user pool already in place, with APIs registered as resource servers that define custom scopes | A client secret and a resource server to configure, a scope of the form `server/scope` instead of an audience, and a charge per token request with no free tier for machine clients | Your identity is not in AWS |
| [Auth0](https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow) | A hosted tenant where each API is registered with an identifier that the token's `audience` names | An `audience` parameter every token request has to carry, and a free tier capped at 30 token requests per second | You run your own authorization server |
| [Authlib](https://docs.authlib.org/en/latest/oauth2/client/http/api.html) | A Python service that already uses `requests` and wants the token fetched and renewed for it | A dependency on `cryptography` and `joserfc`, a Basic header sent without its form encoding, a renewal that re-posts the grant without your extra parameters, and a sixty-second margin to remember | A service with no `requests` in it |
| [`golang.org/x/oauth2/clientcredentials`](https://pkg.go.dev/golang.org/x/oauth2/clientcredentials) | A Go service that wants an `http.Client` which fetches and renews its own token | A module to track, a token treated as expired ten seconds early, and an auth style guessed on the first request unless you set it | You want to see the token request itself |
| [Hand-rolled token POST](https://www.rfc-editor.org/rfc/rfc6749#section-4.4) | Any language, a dozen lines, and every step visible | A cache, a clock, a renewal margin, and the Basic header encoding, each written and tested by you | A library already does it in your language |

The two libraries differ most in what they hide. `x/oauth2` hands back an `http.Client` and you
never see the token request, which is fine until a provider wants a parameter the renewal does not
carry. Authlib shows you `fetch_token` and renews behind the scenes, with the same gap. The
hand-rolled POST has no gap and no renewal until you write both. The hosted providers differ in how the API is named, with Auth0 wanting `audience` and Cognito a
scope prefixed with the resource server's identifier.

## Name the API in the token request

A client credentials request is a POST to the token endpoint with `grant_type=client_credentials`
and the client's own credentials. [RFC 6749 section 2.3.1](https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1) requires the
authorization server to accept them as an HTTP Basic header, which is `client_secret_basic`. It
merely permits them in the form body as `client_id` and `client_secret`, the `client_secret_post`
method, and marks that form not recommended.[^1] Both libraries below default to the header and switch on
request. The same section has the id and the secret form-urlencoded before they go into the
header. `x/oauth2` encodes them, but Authlib sends them as they are, so a server that decodes them
refuses an Authlib secret holding `+` or `%` until it moves to the body.

The request also names the API the token is for, and the two large hosted providers name it
differently. Auth0 wants an
[`audience` parameter](https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow/call-your-api-using-the-client-credentials-flow)
carrying the identifier of the API as registered in the tenant. Amazon Cognito wants a
[`scope` of the form `resource-server-identifier/scope-name`](https://docs.aws.amazon.com/cognito/latest/developerguide/token-endpoint.html),
with the app client holding a secret and the scope enabled on it. Auth0 puts the identifier in the
token's `aud` claim. Cognito keeps it in `scope` and writes an
[`aud` claim](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-access-token.html)
only when the request binds the token to an API resource. Where `aud` is present,
[RFC 9068](https://www.rfc-editor.org/rfc/rfc9068#section-4) says the resource server must reject
a token whose `aud` does not name it. That rule is why a token minted for one API does not open
another.

In Go the audience travels as an extra endpoint parameter, and the scope as a list.

```go title="go/main.go"
func machineClient(ctx context.Context, tokenURL string) (oauth2.TokenSource, *http.Client) {
	conf := &clientcredentials.Config{
		ClientID:       clientID,
		ClientSecret:   clientSecret,
		TokenURL:       tokenURL,
		Scopes:         []string{"invoices:read"},
		EndpointParams: url.Values{"audience": {audience}},
	}
	source := conf.TokenSource(ctx)
	return source, oauth2.NewClient(ctx, source)
}
```

## Reuse the token in Go through a token source

`conf.TokenSource` wraps the fetch in a `ReuseTokenSource`, which hands back the same token until
ten seconds before its expiry and only then posts to the token endpoint again. `oauth2.NewClient`
turns that into an `http.Client` that sets the bearer header on every request. The stand-in counts
what the client did.

```bash
cd go && go run .
```

```text output
one token source, five API calls
  responses:             5 x 200
  token requests:        1
  client authentication: client_secret_basic
  token_type:            Bearer
  aud:                   https://api.example.com
  scope:                 invoices:read
  token lifetime:        1h0m0s
a token per call, twelve calls
  token requests:        12
  refused with 429:      2
```

Five calls, one token request, authenticated with the Basic header because `x/oauth2` tries the
header first when `AuthStyle` is left at its zero value and remembers what worked. The token's
`aud` and `scope` are read by decoding the payload, which a client may do to look and a resource
server must never do to trust. The second block is the pitfall. `conf.Token` builds a fresh source each time, so twelve calls are
twelve token requests. The stand-in's limit of ten refuses the last two with 429 before the API has
seen a call.

## Reuse the token in Python with Authlib, or by hand

Authlib's `OAuth2Session` fetches the token with `fetch_token` and, when it knows the token
endpoint and the grant type, renews it on its own once the token is inside its sixty second
`leeway`. The renewal posts only `grant_type` and the scope, so the `audience` an Auth0 tenant
insists on would be missing from it. The subclass puts it back.

```py title="python/client.py"
class MachineTokenSession(OAuth2Session):
    """Authlib's session, which fetches a client credentials token and renews it once the
    current one is inside its 60 second leeway, plus one fix: the renewal Authlib makes
    on its own posts grant_type and scope alone, so the audience the API is named by
    would be dropped. This subclass puts it back on every token request."""

    def __init__(self, client_id: str, client_secret: str, *, token_url: str, audience: str, scope: str,
                 auth_method: str = "client_secret_basic"):
        super().__init__(client_id, client_secret, token_endpoint_auth_method=auth_method, scope=scope,
                         token_endpoint=token_url, grant_type="client_credentials")
        self.audience = audience

    def fetch_token(self, url=None, **kwargs):
        kwargs.setdefault("audience", self.audience)
        return super().fetch_token(url, **kwargs)
```

The hand-rolled version in the same file is the same POST through `urllib`, with the header's
encoding, the cache, and the renewal margin written out, which is what the libraries hide.

```bash
cd python && PYTHONPATH=vendor python3 demo.py
```

```text output
Authlib OAuth2Session, five API calls
  responses:             5 x 200
  token requests:        1
  client authentication: client_secret_basic
  token_type:            Bearer
  aud:                   https://api.example.com
  scope:                 invoices:read
Authlib with token_endpoint_auth_method='client_secret_post'
  client authentication: client_secret_post
hand-rolled POST with a cache, five API calls
  responses:             5 x 200
  token requests:        1
a token per call, twelve calls
  token requests:        12
  refused with 429:      2
```

The counts match Go: one token request for five calls, and twelve token requests for twelve calls
when the token is fetched per call. The secret travels in the Basic header unless
`token_endpoint_auth_method` says `client_secret_post`. The hand-rolled source matches the libraries on the count,
with a sixty-second margin it had to be given.

## Check it worked

```bash
cd go && go test -v ./... | grep -E '^(=== RUN|PASS$|FAIL$)'
```

```text output
=== RUN   TestOneTokenServesManyCalls
=== RUN   TestTokenCarriesAudienceAndScope
=== RUN   TestSecretGoesInTheHeaderByDefaultAndInTheBodyOnRequest
=== RUN   TestWrongSecretIsInvalidClient
=== RUN   TestATokenPerCallTripsTheLimit
=== RUN   TestReservedCharactersInTheSecretSurviveTheHeader
=== RUN   TestTheAPIRefusesAnExpiredToken
PASS
```

```bash
cd python && PYTHONPATH=vendor python3 -m unittest -v test_client 2>&1
```

```text output
test_a_secret_with_reserved_characters_survives_the_basic_header (test_client.ClientCredentials.test_a_secret_with_reserved_characters_survives_the_basic_header) ... ok
test_a_token_per_call_trips_the_limit (test_client.ClientCredentials.test_a_token_per_call_trips_the_limit) ... ok
test_an_expired_token_is_renewed_once (test_client.ClientCredentials.test_an_expired_token_is_renewed_once) ... ok
test_authlib_sends_the_basic_header_unencoded_so_such_a_secret_needs_the_body (test_client.ClientCredentials.test_authlib_sends_the_basic_header_unencoded_so_such_a_secret_needs_the_body) ... ok
test_one_token_serves_many_calls (test_client.ClientCredentials.test_one_token_serves_many_calls) ... ok
test_secret_goes_in_the_header_by_default_and_in_the_body_on_request (test_client.ClientCredentials.test_secret_goes_in_the_header_by_default_and_in_the_body_on_request) ... ok
test_the_api_refuses_an_expired_token (test_client.ClientCredentials.test_the_api_refuses_an_expired_token) ... ok
test_the_hand_rolled_cache_also_holds_one_token (test_client.ClientCredentials.test_the_hand_rolled_cache_also_holds_one_token) ... ok
test_token_carries_audience_and_scope (test_client.ClientCredentials.test_token_carries_audience_and_scope) ... ok
```

The first Go test and the Python test named `one_token_serves_many_calls` are the ones that
matter: five calls and a token request count of `1`. The Python renewal test sets `expires_at` in
the past and expects exactly `2` token requests after three calls, which fails without the
subclass because the renewal arrives without an audience.

## When it goes wrong

The authorization server answers 429 while the API is idle. A token source was built per request,
or one per worker, so the token endpoint sees one request per API call. Auth0's
[free tier](https://auth0.com/docs/troubleshoot/customer-support/operational-policies/rate-limit-policy/rate-limit-configurations/free-public)
allows 30 requests per second to `/oauth/token`, and Cognito puts client credentials requests in a
[quota category](https://docs.aws.amazon.com/cognito/latest/developerguide/quotas.html) of their
own and [charges for each one](https://aws.amazon.com/cognito/pricing/) with no free tier.[^2]
Build one token source per process and share it.

The renewal fails with `invalid_request`, or the renewed token is refused by the API. The renewal request did not carry what the first one did, because Authlib re-posts the grant
without your extra parameters, so an Auth0 audience is dropped, which the subclass fixes.
`x/oauth2` keeps
`EndpointParams` on the `Config` and sends them every time.

Every call fetches a token even with a cache in place. The provider's tokens live for less than the library's margin. `x/oauth2` treats a token as
expired ten seconds early and Authlib sixty seconds early, so a thirty-second token is already
expired to Authlib when it arrives.[^3] Ask the provider for a longer lifetime, or set the margin, with
`ReuseTokenSourceWithExpiry` in Go and the `leeway` argument in Python.

## When not to do this

Do not use client credentials for a call made on behalf of a user. The token says which service is
calling and says nothing about who asked, so the API cannot authorize per user and the audit trail
ends at the service. Exchange the user's token or run the authorization code flow instead.

Do not put the client secret in anything you ship to users. RFC 6749 restricts the grant to
confidential clients, and a mobile app or a browser bundle is not one, whatever the quick start
shows.

Do not decode the token to decide anything. Decoding shows a client what it was given. A resource
server verifies the signature and checks `aud` and `exp`, and a client that skips the server's
check because the claims looked right has built a second, weaker authorization server.

Do not share one client registration between services. One client per service keeps secret
rotation, the scope grant, and revocation per service, so a leaked secret takes down one service
rather than all of them.

Do not leave the audience or scope off because the server accepts the request without it. A token with no audience is a token for every API the issuer protects. RFC 9068 requires a
distinct audience per API so that a token for one does not work at another.

## Related how-tos

- [Refresh an access token once under concurrent requests](/howto/refresh-a-token-once-under-concurrent-requests)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

- [Run authorization code with PKCE in a single-page app](/howto/pkce-in-a-single-page-app)

- [Run the client credentials flow inside a generated SDK](/howto/client-credentials-inside-a-generated-sdk)

## Last verified

Verified 2026-10-20 against Go 1.24.7, golang.org/x/oauth2 v0.35.0, Python 3.13.16, Authlib 1.9.1,
and requests 2.34.2. Every output block is what the command preceding it printed, against the
stand-in authorization server in each sample directory on the loopback interface. Auth0 and
Amazon Cognito were not called; their parameter names, limits, and prices are read from the pages
linked. The stand-in's limit of ten token requests is a figure chosen for the demonstration, and
the `x/oauth2` margin of ten seconds is read from the package source rather than its documentation.

[^1]: [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1) spends one paragraph
requiring Basic authentication and the next permitting the body form, calling it not recommended
and limiting it to clients unable to use Basic. It then adds that the two parameters must never
appear in the request URI, which is the one placement the specification bothers to forbid by
name.

[^2]: Cognito's [pricing page](https://aws.amazon.com/cognito/pricing/) states that there is no
free tier for token requests when Cognito is used for the machine-to-machine use case. Its
[quota table](https://docs.aws.amazon.com/cognito/latest/developerguide/quotas.html) files client
credentials requests under a category of their own, with a default of 150 per second. A token
fetched per call is billed per call, which makes this the rare pitfall with an invoice attached.

[^3]: Authlib's [`leeway`](https://docs.authlib.org/en/latest/oauth2/client/http/api.html) is
documented as a sixty-second window before the actual expiration in which the token is treated as
expired and refreshed. `x/oauth2` keeps its ten seconds in a constant the package does not export and exposes the knob
through [`ReuseTokenSourceWithExpiry`](https://pkg.go.dev/golang.org/x/oauth2#ReuseTokenSourceWithExpiry).
Both figures are stated without a derivation, which is how such figures usually arrive.