Outbound tokens
A service that calls another API authenticates the call. The Outbound Tokens page shows how. This page records why the API has the shape it has, which choices were weighed and dropped, and what waits for a later version.
grelmicro still issues no token and runs no login. It asks an authorization server for a token as a client, the way it asks a JWKS endpoint for keys.
Two grants, two patterns
A service calls another API in one of two ways:
- As itself. The API authorizes the service, and no user is involved. This is most calls, and the only option for work with no request behind it, such as a task, an outbox relay or a leader-elected job.
- For a user. The API needs to know the user, so the token the service received is exchanged for one issued to the API.
The two are independent by protocol. Microsoft Entra ID's on-behalf-of grant
works only for users, and a caller with no user must use client credentials.
So each grant is its own pattern, ClientCredentials and TokenExchange, and a
service uses either one or both. Neither sets the other up.
One source with an on_behalf_of= argument was the first draft and was dropped.
It made exchange a mode of a client credentials source, so a service that only
exchanges still configured a grant it never used.
The client is a component, the target is a pattern
What a service tells the authorization server splits in two:
Held once, on OAuthClient |
Held per API, on the pattern |
|---|---|
| issuer or token endpoint | audience, resource or scopes |
client_id and how the client authenticates |
the pattern's name |
| timeouts, refresh settings, failure memory | the exchange cache |
OAuthClient is a component, registered in uses= and opened by the app. The
patterns find it through the app the way Lock("cart") finds its backend, and
take client= for a second client or one built outside an app. A service
calling three APIs registers its credentials once and names three patterns.
The pattern is named after the API it calls, which follows
a pattern's name is what it protects.
That name is also its environment prefix, so a deployment can set
GREL_CLIENTCREDENTIALS_PAYMENTS_API_AUDIENCE. A target passed on every call
could not be set from the deployment at all.
A provider was the other candidate for OAuthClient. Providers own a vendor
connection and read the vendor's own variables, such as REDIS_URL.
OAuthClient reads GREL_OAUTHCLIENT_, like every component.
The grant is a factory, not a vendor
TokenExchange follows RFC 8693 by
default. The on-behalf-of grant, which Microsoft Entra ID uses, is
TokenExchange.on_behalf_of. The factory names the grant, the way
algorithms use factory classmethods.
A factory named after a vendor was dropped. A provider's quirk on the inbound
side is a setting, algorithm= on JWTVerifier.discover, not a
JWTVerifier.entra. The same holds here.
TokenExchange takes no generic requested_token_type=. A transaction token,
which carries the caller through a trust domain in its own header, is not an
exchange for a target API, and a type switch here would make it look like one.
If it comes, it is a pattern of its own.
A token reaches a request through auth()
token() returns an AccessToken carrying token_type next to value, and
the documented way to attach it is auth(). A sender-constrained token uses
another scheme and a proof per request, so code that writes Bearer by hand
would break without a word the day one arrives. auth() is where that change
would land.
httpx checks an auth object with isinstance against its own Auth class,
and httpx2 against its own. An httpx.Auth subclass handed to an httpx2
client is refused. Starlette's full extra installs both lines, so either can
be the client an application uses. auth() builds one class inheriting from
every Auth installed, and both clients accept it.
Code chooses how the service authenticates
client_auth= is always code: ClientAuth.secret(), .private_key(...) or
.assertion_file(). The environment supplies the secret and the file path, and
never the method. It is the configuration rule that the
environment tunes a value and never chooses an algorithm.
A private key comes from code only, as JWTKey material does. A client secret
is read from the environment, because a mounted Kubernetes Secret is how one
arrives, and it is held as a SecretStr that no error message repeats. It is a
live setting, so ExternalConfig reading a mounted Secret directory rotates it
without a restart. A second way to read a secret from a file was not added,
because that one already exists.
Basic or body
RFC 6749 requires
servers to accept a secret in Authorization: Basic. The OAuth 2.1 draft
reverses that: servers must accept it in the request body, and Basic is
optional. A fixed default is wrong for one of the two.
So ClientAuth.secret() sends the secret the way the server's metadata lists in
token_endpoint_auth_methods_supported. When both are listed it uses Basic,
which every server predating OAuth 2.1 must accept. When the list is absent it
uses Basic, as RFC 8414 says to.
method= pins it. Basic form-encodes the client ID and secret before encoding
them, which a hand-written client often misses.
Which audience a signed assertion names
A signed client assertion names the server it is meant for in aud. The
original RFC 7523 allowed the token endpoint URL. In 2025 researchers showed
that a malicious authorization server can publish an honest server's token
endpoint as its own and replay the assertion it receives there.
RFC 7523bis,
approved and awaiting publication, closes it: the assertion names the issuer,
which discovery has validated, as its only audience, and declares
typ: client-authentication+jwt.
That is the default. Okta and Microsoft Entra ID still document the token
endpoint and refuse the issuer, so audience="token_endpoint" names it, and a
refusal of an issuer-audience assertion says to try it. An assertion naming the
endpoint carries no typ, because the type promises the new rules, and the
update asks servers not to refuse an assertion for lacking one.
Every assertion lives one minute and carries a fresh jti. Keycloak refuses a
reused jti, and Auth0 caps the lifetime at five minutes. An assertion is never
cached.
certificate= adds the x5t#S256 header, the SHA-256 thumbprint of the
certificate, because Microsoft Entra ID finds the key by its certificate rather
than by kid. A SHA-1 x5t is not sent. Entra documents the SHA-256 form, and
the SHA-1 one survives only for AD FS, which this feature does not target.
A private key is read from PEM only. Reading a private JWK would mean converting it to DER by hand, which is the most sensitive code this feature could contain, and PEM is what providers hand out.
The assertion file is read on every fetch
The kubelet rotates a projected service account token in place. Reading the file once would send an expired assertion within the hour, so every fetch reads it again.
Only authorization servers that accept a Kubernetes token as a client assertion work this way: Microsoft Entra ID through federated credentials, and Keycloak 26.6 and later. EKS and GKE workload identity trade the token for cloud credentials through their own token services, which is not OAuth client authentication, so the page does not claim them.
The caller's token is passed, never ambient
TokenExchange.auth(token) takes the caller's token from CurrentToken. An
auth() that read it from the current request was weighed and dropped.
Reading it ambiently means holding it in a context variable. asyncio copies
context into every task a request starts, so a task still running after the
response would keep acting as that user, and a shared client used from a
background job would send a stale user's token without a word. Passing the
token makes the user visible at the call that acts for them.
CurrentToken holds only a token AuthenticatedRequests verified. A token that
did not verify never reaches a handler, so a service never exchanges a
credential it did not check. The authorization server checks the token again,
and grelmicro does not rely on it doing so.
The exchange cache is bounded by the caller
An exchanged token is cached per caller and API. The entry expires at whichever
comes first, the issued token's expiry or the caller's own exp, so a token
issued for a user never outlives the token that user presented. A caller whose
token has no expiry is not cached, because nothing would bound it.
That rules out acting for a user after their token expired, in long-running work. Supporting it would mean keeping a user's delegated credential past the proof they gave, which this feature does not do.
The key is a SHA-256 digest of the caller's token, the client ID, the grant and the target. It never holds the token, for the reason JWT verification gives.
Tokens are not shared across replicas
Each replica caches its own tokens in memory. A shared store would save fetches on a rolling deploy, and a provider that bills or caps token issuance counts each one. It would also put live tokens for every downstream API in a store any reader of that store could use, where today they exist only in the process that sends them.
The saving is small against that. With one fetch per replica per API per token lifetime, twenty replicas calling five APIs with hour-long tokens fetch about a hundred times an hour. Exchanged tokens are never a candidate at all, since a shared store of them is a store of every active user's delegated access.
A 401 retries only what is safe to repeat
RFC 6750 lets a client
fetch a new token after invalid_token and try again. A 401 does not prove
the request went unprocessed, because a gateway can authenticate after acting,
and RFC 9110 says a
client should not repeat a non-idempotent request unless it knows the request
was not applied.
So auth() drops the cached token on every invalid_token, and sends the
request again only for GET, HEAD, OPTIONS, PUT and DELETE, once, and
only when the body can be sent twice. A POST /charges is never sent twice.
Refusals arriving together share one fetch.
A token is dropped for a refusal at most once per retry_interval. An API that
refuses every token, because the audience is wrong or the client is not allowed,
would otherwise turn each outbound call into a token request, and soon into a
429 from the authorization server that stops every other token the client
asks for. Past the first refusal in an interval, the token is kept and the
401 is the caller's to handle.
Some clients resend any method after a 401. An opt-in for that was weighed and
not added. Nothing in a request says it is safe to repeat: the Idempotency-Key
header draft expired without becoming a standard. A caller who knows a POST
is safe can catch the 401 and send it again, and the decision stays at the
call that knows.
Where signing runs
A signed assertion is made once per token fetch, minutes apart, next to a
network round trip of milliseconds. Speed does not decide where signing runs.
What decides it is that grelmicro[jwt] already ships one crypto library in the
compiled core, and signing in Python would add a second one with its own
advisories and its own key parsing to prove. Measured anyway, on the machine
JWT verification uses:
| Signing, key already loaded | µs |
|---|---|
| RS256, core | 273 |
RS256, Python cryptography |
959 |
| ES256, core | 10 |
| Ed25519, core | 4 |
The core does not sign through jsonwebtoken. Its encode parses the key
again on every call, and accepts a corrupt RSA key until the first signature
fails. The signer holds aws-lc-rs key objects built once, which check the key
when they are built, so a bad key fails at startup rather than on the first
fetch. Encrypted PEM is refused with an error saying so.
The PEM arrives as a Python string, which nothing can wipe from memory. The docs claim no zeroization for that reason.
A client secret and an assertion file need no signing, so they need neither the
core nor the extra. The core is imported only by ClientAuth.private_key.
Refresh runs on a request, not a timer
JWTVerifier refreshes keys in a background task, because one key set serves
every request. A token source is different: a client can hold many targets and
many exchanged tokens, most of them idle. A timer per token would wake for
tokens nobody uses.
So a token refreshes when a request finds it inside its refresh window. That request starts one fetch and is served the cached token, and so is every request while the fetch runs. The fetch is a task the client owns, so cancelling the request that started it leaves it running for the others, and closing the client cancels it.
A server that sends refresh_in decides the window. Otherwise it is
refresh_before, capped at half the token's lifetime. A token living sixty
seconds would otherwise be inside its window the moment it arrived, and every
request would start a fetch. The refresh happens at least that window before
expiry, and at random up to half the window earlier than that, so replicas
started together spread their fetches out.
An exchanged token cut short by the caller's own expiry is kept to its end and never refreshed ahead. A refresh exchanges the same caller token, so the new token would end at the same moment, and every refresh would only schedule the next one.
Discovery runs when the client opens, so the first request after startup waits for a token and not for metadata too.
An asyncio task belongs to one event loop. A call arriving from another loop,
such as a synchronous handler going through a thread, is sent to the loop the
client was opened on rather than awaiting a task it cannot await.
Failures are remembered at two levels
grelmicro components do not wrap their own I/O in Retry or CircuitBreaker.
This one follows JWTVerifier instead: a timeout, one shared fetch per key,
and failures remembered for retry_interval.
Remembering matters most when no token is cached. A hundred requests arriving while the server is down would wait on one five second fetch, and the next hundred would each start another. Measured: the first hundred cost one fetch and five seconds, the next hundred cost no fetch and 1.2 milliseconds.
What is remembered, and for whom, depends on what failed:
- A server that does not answer, times out or answers
5xxis down for every token. Remembering it per token would let a thousand users each start a fetch against it, so it is remembered for the whole client. - A
429or503withRetry-Afteris the server asking every caller to wait, so it is remembered for the whole client, for as long as it asks and at most an hour, which bounds a server sending a mistaken value. - A refusal of one token, such as
invalid_granton one user's exchange, says nothing about anyone else, so it is remembered for that token only. That holds for any4xxbut408and429, with or without an error code: a server that denies one user with a403must not let that user stop every other token the client asks for.
A connection lost before any answer is retried once, at once. It is the one failure a retry fixes, because a keep-alive connection closed by the other side is common, and a token request repeated costs at most a second token. A timeout is not retried, because it already spent the wait.
invalid_client and unauthorized_client are raised as ClientRejectedError.
They are configuration errors, not outages, and retrying them never succeeds.
They are not remembered for the same whole, though. invalid_client refuses the
service itself, so it is remembered for the whole client. unauthorized_client
refuses one grant the client may not use, so it is remembered for that grant,
and a client allowed client credentials but not token exchange keeps getting its
own tokens.
Nothing is recorded per request
A request reusing a cached token costs a dictionary lookup, 50 nanoseconds, and
setting a header, about one microsecond through httpx. Metrics, spans and
events are recorded per fetch, never per request, so observing the client adds
nothing to that cost.
Trace redaction also hides client_assertion, assertion and subject_token,
the form fields a token request carries.
A platform can make this unnecessary
A service mesh or a gateway that injects a client credentials token on outgoing calls covers the simplest case with no code. The ones available today inject a token fetched with a client secret, and none exchanges a user's token. The user page says when to use the platform and when grelmicro adds what it cannot.
What waits, and why
Every item below was proposed during review and left out of the first version. Each can be added later without changing anything that ships, and none had the evidence to go in now.
| Waits | Why | What ships now so it can land later |
|---|---|---|
An actor token on TokenExchange |
Few servers accept one, and a plain exchange covers the common case. | The exchange cache key is built in one place, so an actor can join it. |
| Signing with a key in a KMS or an HSM | No reference client offers a byte signer, and a private key or workload identity covers most services. | Assertions are built in one function, so a signer can supply only the signature. |
| A SPIFFE assertion type | The client authentication draft has no major server behind it yet. | assertion_file sends jwt-bearer, and the type is one value in one place. |
| Continuous access evaluation claims challenges | Entra sends them only to clients declaring the cp1 capability, only from Microsoft Graph, only for single-tenant apps. Without declaring it, a handler never runs. |
Nothing. It needs extra token request parameters first. |
Extra token request parameters, such as Auth0's organization |
Real demand, but additive, and each provider names them differently. | Token requests are built as a form in one place. |
bulkhead= on OAuthClient |
No reference client caps token fetches, and fetches are already shared per key. | Nothing. A keyword is additive. |
fetch= for your own HTTP client |
The default client already reads the proxy and certificate variables. | Nothing. A keyword is additive. |
A stale outcome and a counter for refused tokens |
No reference client records them. | The outcome attribute is an open set. |
Synchronous httpx.Client |
grelmicro is async first, and a synchronous flow cannot await a shared fetch. | Nothing. |