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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Amazon Cognito | 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 | 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 | 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 | 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 | 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 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.
Related how-tos
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
-
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
-
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
-
Authlib’s
leewayis documented as a sixty-second window before the actual expiration in which the token is treated as expired and refreshed.x/oauth2keeps its ten seconds in a constant the package does not export and exposes the knob throughReuseTokenSourceWithExpiry. Both figures are stated without a derivation, which is how such figures usually arrive. ↩︎ Back to text