How-to › Authenticate and authorize calls

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.

Audience
Platform team
Level
beginner
Topic
Run OAuth 2.1 and OIDC flows
Languages
Go and Python
Verified

A billing service calls the invoicing API every few seconds with no user anywhere in the chain. Its first version fetched a token before each call, because that was the shape of the quick start. A week later the authorization server’s rate limit tripped at the start of a month-end batch. The limit was the authorization server’s, not the API’s.

What you get

You will end up with Go and Python clients that fetch one token, reuse it until it nears expiry, and prove it with a test that counts token requests. This is for you if a backend of yours calls an API with no user present.

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, for confidential clients only.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Amazon CognitoA user pool already in place, with APIs registered as resource servers that define custom scopesA 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 clientsYour identity is not in AWS
Auth0A hosted tenant where each API is registered with an identifier that the token’s audience namesAn audience parameter every token request has to carry, and a free tier capped at 30 token requests per secondYou run your own authorization server
AuthlibA Python service that already uses requests and wants the token fetched and renewed for itA 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 rememberA service with no requests in it
golang.org/x/oauth2/clientcredentialsA Go service that wants an http.Client which fetches and renews its own tokenA module to track, a token treated as expired ten seconds early, and an auth style guessed on the first request unless you set itYou want to see the token request itself
Hand-rolled token POSTAny language, a dozen lines, and every step visibleA cache, a clock, a renewal margin, and the Basic header encoding, each written and tested by youA 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 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 carrying the identifier of the API as registered in the tenant. Amazon Cognito wants a scope of the form resource-server-identifier/scope-name, 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 only when the request binds the token to an API resource. Where aud is present, RFC 9068 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.

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.

cd go && go run .
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.

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.

cd python && PYTHONPATH=vendor python3 demo.py
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

cd go && go test -v ./... | grep -E '^(=== RUN|PASS$|FAIL$)'
=== RUN   TestOneTokenServesManyCalls
=== RUN   TestTokenCarriesAudienceAndScope
=== RUN   TestSecretGoesInTheHeaderByDefaultAndInTheBodyOnRequest
=== RUN   TestWrongSecretIsInvalidClient
=== RUN   TestATokenPerCallTripsTheLimit
=== RUN   TestReservedCharactersInTheSecretSurviveTheHeader
=== RUN   TestTheAPIRefusesAnExpiredToken
PASS
cd python && PYTHONPATH=vendor python3 -m unittest -v test_client 2>&1
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 allows 30 requests per second to /oauth/token, and Cognito puts client credentials requests in a quota category of their own and charges for each one 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.

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.

Footnotes

  1. RFC 6749 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. ↩︎ Back to text

  2. Cognito’s pricing page states that there is no free tier for token requests when Cognito is used for the machine-to-machine use case. Its quota table 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. ↩︎ Back to text

  3. Authlib’s leeway 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. Both figures are stated without a derivation, which is how such figures usually arrive. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.