Skip to content

Outbound Tokens

A service that calls another API needs a token for it. grelmicro gets that token from your authorization server, caches it, and refreshes it before it expires.

grelmicro still issues nothing and runs no login. The authorization server issues the token. Your service asks for it as a client.

There are two ways to get one, and a service uses either or both:

Pattern The token says Use it when
ClientCredentials "orders-api is calling" The other API authorizes your service. This is most calls.
TokenExchange "orders-api is calling for this user" The other API needs to know the user too.

Install

Tokens are fetched with httpx or httpx2, whichever your application already has. A client secret or an assertion file needs nothing else.

A private key signs in the compiled core, which ships as its own wheel:

pip install "grelmicro[jwt]"

Register the client

Your service is registered at the authorization server as a client. Register it once in the app, next to everything else:

from grelmicro import Grelmicro
from grelmicro.security import ClientAuth, OAuthClient

micro = Grelmicro(
    uses=[
        OAuthClient.discover(
            "https://auth.example.com/",
            client_id="orders-api",
            client_auth=ClientAuth.secret(),
        ),
    ]
)

discover finds the token endpoint in the issuer's metadata, the same way JWTVerifier.discover finds the keys. It reads the metadata when the app opens. ClientAuth.secret() reads the secret from GREL_OAUTHCLIENT_CLIENT_SECRET.

Call an API as your service

Name the API you call, and hand the pattern's auth() to the client that calls it:

import httpx
from fastapi import FastAPI

from grelmicro import Grelmicro
from grelmicro.security import ClientAuth, ClientCredentials, OAuthClient

# The service is registered with its authorization server once. The secret is
# read from GREL_OAUTHCLIENT_CLIENT_SECRET.
micro = Grelmicro(
    uses=[
        OAuthClient.discover(
            "https://auth.example.com/",
            client_id="orders-api",
            client_auth=ClientAuth.secret(),
        ),
    ]
)

# One token for the payments API, fetched on first use and kept fresh.
payments_token = ClientCredentials("payments-api", audience="payments-api")
payments = httpx.AsyncClient(
    base_url="https://payments.internal", auth=payments_token.auth()
)

app = FastAPI()


@app.post("/orders")
async def create_order() -> dict[str, str]:
    response = await payments.post("/charges", json={"amount": 100})
    return response.json()


micro.install(app)

Every request payments sends carries a token issued for payments-api. The first request fetches it. The ones after it reuse it.

ClientCredentials finds the registered OAuthClient through the app, the way Lock finds its backend. Pass client="partner" to use a client registered with name="partner", or the OAuthClient itself.

auth() is how a token should reach a request. It sets the right scheme and handles a refused token. To read the token for another client, await token():

fragment
token = await payments_token.token()
headers = {"Authorization": f"{token.token_type} {token.value}"}

token.expires_at is when it expires and token.scopes is what the server granted. Its repr never shows the value.

Call an API for the user

TokenExchange trades the token your service received for one issued to the next API, with the user still its subject:

import httpx
from fastapi import FastAPI

from grelmicro import Grelmicro
from grelmicro.http import AuthenticatedRequests
from grelmicro.integrations.fastapi import CurrentToken
from grelmicro.security import (
    ClientAuth,
    JWTVerifier,
    OAuthClient,
    TokenExchange,
)

micro = Grelmicro(
    uses=[
        # Verifies the token each request presents.
        AuthenticatedRequests(
            JWTVerifier.discover(
                "https://auth.example.com/", audience="orders-api"
            )
        ),
        # Exchanges it for tokens issued to the APIs the service calls.
        OAuthClient.discover(
            "https://auth.example.com/",
            client_id="orders-api",
            client_auth=ClientAuth.secret(),
        ),
    ]
)

payments_for_user = TokenExchange("payments-api", audience="payments-api")
payments = httpx.AsyncClient(base_url="https://payments.internal")

app = FastAPI()


@app.post("/orders")
async def create_order(token: CurrentToken) -> dict[str, str]:
    # The token issued for payments-api still names the caller.
    response = await payments.post(
        "/charges",
        json={"amount": 100},
        auth=payments_for_user.auth(token),
    )
    return response.json()


micro.install(app)

CurrentToken is the bearer token AuthenticatedRequests verified for this request, next to CurrentPrincipal. On Starlette and Litestar, read it with current_token(request).

Only a token AuthenticatedRequests verified can be exchanged. Your service never trades a credential it did not check.

An exchanged token is cached per user and API, and never outlives the token it was exchanged for. The cache keys on a digest of that token, never on the token. A caller whose token carries no expiry, such as an opaque one, is not cached.

TokenExchange follows RFC 8693, which Keycloak and Okta support. Microsoft Entra ID uses the on-behalf-of grant instead:

TokenExchange.on_behalf_of("payments-api", scopes=["api://payments/.default"])

Naming the API you call

Providers name the target differently. Pass what yours expects:

Argument Sent as Providers
audience= audience Auth0, Keycloak, Okta
resource= resource, RFC 8707 Okta, PingFederate
scopes= scope Microsoft Entra ID, AWS Cognito

Authenticating your service

Here the client is your service, as the authorization server knows it. Pick how it proves who it is:

ClientAuth.secret()
ClientAuth.private_key(private_pem, algorithm="RS256", kid="2026-09")
ClientAuth.assertion_file()
  • secret sends the client secret. It reads GREL_OAUTHCLIENT_CLIENT_SECRET unless you pass the secret. It is sent the way the server's metadata says it accepts, in Authorization: Basic when the server lists both. method="basic" or method="post" pins it for a server that publishes no metadata.
  • private_key signs a one-minute assertion with your key, as RFC 7523 defines, with a fresh jti every time. The key only comes from code.
  • assertion_file sends a signed assertion read from a file on every fetch. It reads the path from GREL_OAUTHCLIENT_ASSERTION_FILE unless you pass it.

Code chooses how your service authenticates. The environment only supplies the secret or the path. No log line, error message or repr shows a secret, a key, an assertion or a token.

Which audience a signed assertion names

The assertion names the authorization server it is meant for. The update to RFC 7523, RFC 7523bis, approved and awaiting publication, requires the issuer, because an assertion naming an endpoint URL can be replayed by a server that claims that URL. That is the default.

Okta and Microsoft Entra ID still require the token endpoint. Say so:

ClientAuth.private_key(private_pem, algorithm="RS256", audience="token_endpoint")

A server that refuses an assertion naming the issuer raises ClientRejectedError with a message suggesting audience="token_endpoint".

Not every provider accepts every algorithm. Okta takes RSA and ECDSA only.

Kubernetes workload identity

On Kubernetes, point assertion_file at a projected service account token whose audience your authorization server expects. The kubelet rotates the file, and every fetch reads it again.

Microsoft Entra ID accepts it through federated credentials, from Azure Kubernetes Service or any other cluster. On Azure Kubernetes Service, the workload identity webhook names the file:

GREL_OAUTHCLIENT_ASSERTION_FILE=$AZURE_FEDERATED_TOKEN_FILE

Keycloak 26.6 and later accept it through federated client authentication.

EKS and GKE workload identity are not OAuth client authentication. They trade the token for cloud credentials, which their own SDKs do.

Rotating a secret

A client secret mounted from a Kubernetes Secret rotates without a restart. Read the mounted directory with ExternalConfig, and a new GREL_OAUTHCLIENT_CLIENT_SECRET file applies to the next fetch. A token already cached stays valid until it expires.

Microsoft Entra ID

Name the tenant by its ID. The shared common and organizations endpoints publish an issuer holding a {tenantid} placeholder, which never matches, so discovery refuses them.

OAuthClient.discover(
    f"https://login.microsoftonline.com/{tenant_id}/v2.0",
    client_id=client_id,
    client_auth=ClientAuth.private_key(
        private_pem,
        algorithm="PS256",
        certificate=certificate_pem,
        audience="token_endpoint",
    ),
)

Entra finds the key by its certificate rather than by kid, which is what certificate= provides.

Refresh

A token is refreshed shortly before it expires, by the first request that finds it close to expiring. That request, and every request after it, is served the cached token while one fetch runs. A burst of requests costs the authorization server one fetch, and a request cancelled while it waits does not cancel the fetch the others are waiting on.

A server that says when to refresh, with refresh_in, is followed. Otherwise the token refreshes at least refresh_before before it expires, capped at half its lifetime, and at random up to half of refresh_before earlier than that, so replicas started together do not all ask in the same second.

When the authorization server fails

A refresh that fails keeps serving the cached token until it expires.

A fetch that loses its connection before any answer is sent again at once, one time. A token request is safe to repeat, since the worst case is a second token. A timeout is not retried.

Once no valid token is left, token() and auth() raise TokenUnavailableError. Failures are remembered so requests fail at once instead of each waiting on a server that is down:

Failure Remembered for Applies to
No answer, a timeout, a 408 or a 5xx retry_interval every token of this client
429 or 503 with Retry-After what the server asked, at most an hour every token of this client
Any other refusal, such as invalid_grant or a 403 retry_interval that token only
invalid_client, the service itself refused retry_interval every token of this client
unauthorized_client, a grant the service may not use retry_interval every token of that grant

A server that is down fails every token at once, while one user whose exchange is refused does not block anyone else.

ClientRejectedError, a TokenUnavailableError, means the server refused your service itself: a wrong secret, a key it does not know, or a client not allowed this grant. Waiting does not fix it, so it is its own error. error.error holds the code the server sent, such as invalid_client. The server's description is kept on the error and never logged, because a server can echo input into it.

When the API refuses the token

An API can refuse a token before its exp, when its keys rotate or the token is revoked. On a 401 with error="invalid_token", auth() drops the cached token, so the next request gets a new one.

It sends the refused request again, once, only when the method is safe to repeat: GET, HEAD, OPTIONS, PUT or DELETE. A 401 does not prove the request was not processed, since a gateway can authenticate after acting, so a POST is never sent twice. A request whose body cannot be sent twice, such as a streamed upload, is not retried either. To retry a POST you know is safe, catch the 401 and send it again yourself.

Many requests refused at once cost one fetch, not one each. A token refused again within retry_interval of the last refused one is kept, and the 401 reaches your code, so an API that refuses every token, such as one expecting another audience, costs one fetch per interval rather than one per request.

Proxies and certificates

Tokens are fetched over https, and redirects are not followed: a token endpoint that answers from somewhere else is somewhere else. The client reads the standard proxy and certificate variables, HTTPS_PROXY and SSL_CERT_FILE, from the environment.

Seeing what it does

Each fetch counts grelmicro.oauth_client.fetches, with the client, the grant and the outcome, and records grelmicro.oauth_client.fetch.duration. A request that reuses a cached token records nothing.

Each fetch is one span, and no span carries a token, a secret or an assertion. ClientRejectedError also writes a record on the grelmicro.security.events logger, because a client the server no longer accepts is often a rotated or leaked credential.

Settings

Setting On Default What it bounds
refresh_before OAuthClient 60 A token is refreshed at least this long before it expires, at most half its lifetime
default_lifetime OAuthClient 300 Lifetime of a token sent without expires_in
timeout OAuthClient 5 Wait on the authorization server
retry_interval OAuthClient 5 How long a failure is remembered
cache_size TokenExchange 1024 Exchanged tokens held in memory

Configure from the deployment

Leave a setting out of the code for the deployment to supply it. A keyword always wins:

OAuthClient.discover(client_auth=ClientAuth.secret())
ClientCredentials("payments-api")
GREL_ENV_LOAD=1
GREL_OAUTHCLIENT_ISSUER=https://auth.example.com/
GREL_OAUTHCLIENT_CLIENT_ID=orders-api
GREL_OAUTHCLIENT_CLIENT_SECRET=...
GREL_CLIENTCREDENTIALS_PAYMENTS_API_AUDIENCE=payments-api

GREL_OAUTHCLIENT_ISSUER is refused for a client built with endpoint, and a secret set for a client that signs its assertions is refused rather than ignored.

Without discovery

Give OAuthClient.endpoint the token endpoint when the server publishes no metadata:

OAuthClient.endpoint(
    "https://auth.example.com/oauth2/token",
    client_id="orders-api",
    client_auth=ClientAuth.secret(method="post"),
)

A signed assertion needs the issuer to name as its audience, so pass issuer= too, or audience="token_endpoint".

Without an app

Open the client yourself and pass it to the pattern:

fragment
oauth = OAuthClient.discover(...)
payments_token = ClientCredentials("payments-api", client=oauth, audience="payments-api")

async with oauth:
    token = await payments_token.token()

When your platform already does this

A service mesh or an API gateway can attach a client credentials token to outgoing calls for you. If one token per upstream API is all you need and your platform already injects it, use that and skip this page.

Use grelmicro when a call acts for a user, when your service signs its assertions or uses workload identity, when a handler needs to see why a token was refused, or when there is no mesh.

Not covered

  • Sender-constrained tokens, DPoP proofs and mutual TLS client authentication. They follow inbound support for the same tokens.
  • Acting for a user after their token expired, in long-running work. An exchanged token never outlives the caller's.
  • Sharing tokens across replicas. Each replica fetches its own, so no store holds live tokens. A provider that bills or caps token issuance counts one fetch per replica per API per token lifetime.
  • Transaction tokens, which carry the caller through a trust domain in their own header. They would be a pattern of their own.