# How to run the client credentials flow inside a generated SDK

> Make the generated client fetch and renew its own machine token, through a generator setting or a feature you write, and prove that one token serves many calls.

Source: https://voxgig.com/howto/client-credentials-inside-a-generated-sdk

- Audience: api-consumer
- Level: advanced
- Languages: typescript, javascript, go
- Verified: 2026-10-21
- Published: 2026-10-17

## Short answer

Make the generated SDK's transport fetch, cache, and renew the machine token, so application code never handles it. Speakeasy generates the exchange from an `oauth2` scheme with a `tokenUrl`, and OpenAPI Generator's Java client ships a `RetryingOAuth` interceptor. With sdkgen you write the feature: it wraps the transport, caches the token until a margin before `expires_in`, and buys one replacement on a 401. Initialize it before `retry`, so every retried attempt carries a current token.

---
## You will need

Node 22 or later, Go 1.24 or later, an OpenAPI 3 document whose security scheme is `oauth2` with
a `clientCredentials` flow and a `tokenUrl`, and a client id and secret registered at that
endpoint. Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, `@voxgig/create-sdkgen` 0.30.8,
`@voxgig/sdkgen` 4.35.0, and `golang.org/x/oauth2` v0.35.0. The sample's TypeScript SDK was
generated once by `generate.sh` and its compiled output is committed under `generated/ts`, so
every command runs offline against an in-process stand-in. The grant is section 4.4
of [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749.html#section-4.4); the sample's token
endpoint takes `client_secret_basic`, the client authentication the RFC requires every server to
support.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Hand-written transport wrapper](https://pkg.go.dev/net/http#RoundTripper) | Any client that takes an HTTP client or a fetch function, in a language with a library such as `golang.org/x/oauth2` | A wrapper per language and per client to keep in step, and an expiry rule you own | The generator already fetches the token for every language you ship |
| [OpenAPI Generator](https://openapi-generator.tech/docs/generators/java/) | A Java client on the default `okhttp-gson` library, whose `RetryingOAuth` interceptor runs the grant | Renewal only after a 401 or 403, with `expires_in` never read, so every token lifetime ends with one refused request | You generate another language, or one refused request per token lifetime is too many |
| [sdkgen custom feature](https://github.com/voxgig/sdkgen/blob/main/docs/how-to/add-a-feature.md) | An sdkgen project where the token policy should be yours and should run at the transport seam | A model file and a source file per target, written and tested by you, because no shipped feature runs this grant | The generator you use already manages the token, or you ship many sdkgen targets |
| [Speakeasy](https://www.speakeasy.com/docs/sdks/customize/authentication/oauth) | A Speakeasy SDK whose document declares `oauth2` with `clientCredentials` and a `tokenUrl` | A fixed policy: `client_secret_post` by default, a sixty-second expiry buffer, and a 401 that fails the call before the next one renews | You need another client authentication in a language the docs mark unsupported, or a renewal that retries the same call |

Voxgig maintains sdkgen. It is one of four options here, not the recommendation.

Speakeasy and OpenAPI Generator decide the policy for you. The Speakeasy hook treats a token as
expired sixty seconds early, and the Java interceptor keeps one until the server refuses it, a 401
per lifetime. The sdkgen feature and the hand-written wrapper make the policy yours, at the price
of a wrapper to port for each language you ship.

## Write the feature at the transport seam

sdkgen ships no feature for the client credentials grant; its `secrets` feature trades a refresh
token for an access token over JSON. A feature is a model file
plus one source file per target, placed where the
[add-a-feature guide](https://github.com/voxgig/sdkgen/blob/main/docs/how-to/add-a-feature.md)
says. The model declares the seam, `transport: 'wrap'`, the caller's options under
`feature.oauth`, and an `optspec` entry so a test can inject a clock.

```aontu title="oauth-feature/.sdk/model/feature/oauth.aontu"
main: kit: feature: oauth: {

  name: key()
  title: "OAuth client credentials"
  description: "Fetch a machine token with the client credentials grant, reuse it until it nears expiry, and buy one replacement on a 401"
  version: '0.0.1'
  active: true
  base: 'BASE'
  transport: 'wrap'

  config: {
    # Opt-in: enable per SDK via options.feature.oauth.active = true
    options: {
      active: false
      tokenUrl: ''
      clientId: ''
      clientSecret: ''
      scope: ''
      # Seconds before expires_in runs out at which the token counts as spent.
      margin: 10
    }
  }

  # The feature wraps the transport in init(); it dispatches no pipeline hooks.
  hook: {}
}


main: kit: feature: oauth: config: optspec: {
  # injected clock in milliseconds, so a test need not wait a minute
  now: '`$FUNCTION`'
}
```

The source replaces `utility.fetcher`, the function every HTTP attempt goes through, with one
that calls the previous fetcher. The
[hooks reference](https://github.com/voxgig/sdkgen/blob/main/docs/reference/hooks.md) says that
seam handles every HTTP attempt where a hook handles one operation, and a retried attempt is a
fresh request that must carry a current token.

```ts title="oauth-feature/.sdk/tm/ts/src/feature/oauth/OauthFeature.ts"
init(ctx: Context, options: FeatureOptions): void | Promise<any> {
  this._client = ctx.client
  this._options = options || {}
  this.active = (options as any).active

  if (!this.active) {
    return
  }

  const self = this
  const utility = ctx.utility
  const inner = utility.fetcher
  this._fetch = ctx.options.system.fetch

  utility.fetcher = async function (ctx2: any, url: string, fetchdef: any) {
    return self._withToken(ctx2, url, fetchdef, inner)
  }
}


async _withToken(this: any, ctx: any, url: string, fetchdef: any, inner: any): Promise<any> {
  // Test mode never reaches a network, so it never needs a token.
  if ('live' !== ctx.client._mode) {
    return inner(ctx, url, fetchdef)
  }

  const headers = fetchdef.headers = fetchdef.headers || {}
  const used = await this._current(ctx, fetchdef.signal)
  headers.authorization = 'Bearer ' + used
  let res = await inner(ctx, url, fetchdef)

  // The server says the token is spent: revoked, or expired by a clock this
  // client does not share. Drop it, unless a concurrent attempt has already
  // replaced it, then try once more with whatever is current. A second 401
  // is a real failure and goes back to the caller as it is.
  if (null != res && 401 === res.status) {
    if (null != this._token && used === this._token.value) {
      this._token = null
    }
    this._track('renewals')
    headers.authorization = 'Bearer ' + await this._current(ctx, fetchdef.signal)
    res = await inner(ctx, url, fetchdef)
  }

  return res
}
```

`_current` holds one token and one in-flight request, so eight concurrent attempts on a cold
client share a single token request. `_request` posts `grant_type=client_credentials` with the
credentials in a Basic authorization header and records when to replace the token.

```ts title="oauth-feature/.sdk/tm/ts/src/feature/oauth/OauthFeature.ts"
const token = await res.json()
const ttl = Number(token.expires_in)
const margin = null == opts.margin ? 10 : opts.margin

// expires_in is only RECOMMENDED by RFC 6749 section 5.1. Without it the
// token is kept until a 401 says otherwise.
const expiresAt = Number.isFinite(ttl) ? this._now() + Math.max(0, ttl - margin) * 1000 : Infinity
this._token = { value: token.access_token, expiresAt }
```

## Add it by path and regenerate

`feature add` takes a bare name for a shipped feature, or a path to a directory shaped like the
generator's scaffold: a `.sdk` folder with the model under `model/feature` and the source under
`tm/<target>`.

```bash title="generate.sh"
npx --yes @voxgig/create-sdkgen@0.30.8 fulfil --def api.json --target ts
(cd fulfil-sdk/.sdk && npx voxgig-sdkgen feature add retry)
(cd fulfil-sdk/.sdk && npx voxgig-sdkgen feature add ../../oauth-feature/oauth)
(cd fulfil-sdk/.sdk && npm run generate)
```

The add registered the model in `feature-index.aontu` and copied the source into
`.sdk/tm/ts/src/feature/oauth/`, with `ProjectName` replaced by `Fulfil`. Generation wired the
class in beside the shipped features and added its options to the constructor's schema.

```js title="generated/ts/Config.js"
const FEATURE_CLASS = {
    oauth: OauthFeature_1.OauthFeature,
    retry: RetryFeature_1.RetryFeature,
    test: TestFeature_1.TestFeature,
};
```

## Put retry outside the token

The [feature catalogue](https://github.com/voxgig/sdkgen/blob/main/docs/reference/features.md)
says the transport wrapper initialized last ends up outermost and handles a call first. The default map form sorts
names alphabetically, so `oauth` already sits inside `retry`; the array form makes the choice
visible.

```js title="sdk.mjs"
// ['oauth', 'retry'] initializes oauth first, so retry is outermost and every
// attempt it makes passes through oauth on its way to the transport.
export function makeClient(api, clock, order = ['oauth', 'retry'], { margin } = {}) {
  const features = {
    oauth: oauth({ tokenUrl: api.tokenUrl, clock, margin }),
    retry: retry({ clock }),
  }
  return new FulfilSDK({ base: api.base, feature: order.map((name) => features[name]) })
}
```

The token lives sixty seconds, and the API answers the first two attempts with a 503 and `Retry-After: 30`. The retry feature's injected
sleep moves the shared clock instead of waiting, so the third attempt leaves after the token has
expired.

```bash
node demo.mjs
```

```text output
one token for many calls
  50 calls inside the lifetime         token requests 1   refused 0
two minutes of calls, one per second
  120 calls                            token requests 3   refused 0   renewed at 50 s and 100 s
eight calls at once on a cold client
  8 concurrent calls                   token requests 1   refused 0
a token revoked on the server
  1 call after revocation              token requests 2   refused 1   renewals 1   result 2 orders
a 503 with Retry-After 30, twice, so the token expires during the backoff
  retry outside oauth (this page)      requests 3   token requests 2   refused 0   result 2 orders
  oauth outside retry                  requests 4   token requests 2   refused 1   result 2 orders
```

The first block is the point: fifty calls, one token request. The second renews at 50 and 100
seconds, ten seconds before each expiry, with no refused request.

With `retry` outermost, the third attempt passes through the `oauth` wrapper, which sees the
expired token and buys a second one before the request leaves. With `oauth` outermost, the token
was attached before the retry loop, so the third attempt is refused and the wrapper buys a token
for a fourth. The call succeeds either way, but the wrong order costs a 401 on the wire and a round
trip on every call that straddles an expiry.

## Compare the policies the other generators ship

Speakeasy generates the mechanism when the document declares `oauth2` with a `clientCredentials`
flow and `gen.yaml` sets `OAuth2ClientCredentialsEnabled`. The generated hook, visible in
[a published Speakeasy SDK](https://github.com/formancehq/formance-sdk-typescript/blob/main/src/hooks/clientcredentials.ts),
sends `client_secret_post` by default and keeps one session per client id, secret, and scope
set. Its
[session store](https://github.com/formancehq/formance-sdk-typescript/blob/main/src/lib/oauth2-sessions.ts)
treats a token as expired sixty seconds early, and a token without `expires_in` as never
expiring.[^2] A 401 deletes the session rather than retrying: that call fails, and the next one
buys a token.

OpenAPI Generator's Java client, on its default `okhttp-gson` library, ships
[`RetryingOAuth`](https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/resources/Java/libraries/okhttp-gson/auth/RetryingOAuth.mustache),
an OkHttp interceptor built on Apache Oltu.[^3] It fetches a token on the first request, never
reads `expires_in`, and on a 401 or 403 refreshes once and retries once. The template's comment
explains the choice: "401/403 response codes most likely indicate an expired access token,
unless it happens two times in a row."

In Go the transport is the idiom whatever generated the client: `Config.Client` in
[`golang.org/x/oauth2/clientcredentials`](https://pkg.go.dev/golang.org/x/oauth2/clientcredentials)
returns an `http.Client` whose transport adds and renews the token, and any SDK that accepts an
`*http.Client` inherits it.

```go title="go/main.go"
cfg := &clientcredentials.Config{
  ClientID:     "svc-fulfil",
  ClientSecret: "s3cr3t-fulfil",
  TokenURL:     s.srv.URL + "/oauth/token",
  Scopes:       []string{"orders:read"},
  AuthStyle:    oauth2.AuthStyleInHeader,
}
ctx := context.Background()

// cfg.Client wraps the default transport in one that adds the token and
// renews it when it expires. Any SDK that takes an *http.Client takes this.
client := cfg.Client(ctx)
```

```bash
go run -C go .
```

```text output
expires_in 60                                  calls 10  ok 10  token requests  1
expires_in 5                                   calls 10  ok 10  token requests 10
expires_in 5, ReuseTokenSourceWithExpiry(1s)   calls 10  ok 10  token requests  1
```

The middle line is the trap. The package treats a token as expired ten seconds early, a
constant in [`token.go`](https://github.com/golang/oauth2/blob/master/token.go) that its comment
attributes to client-server time mismatches. A five-second token is therefore spent on arrival,
and every call buys another;
[`ReuseTokenSourceWithExpiry`](https://pkg.go.dev/golang.org/x/oauth2#ReuseTokenSourceWithExpiry)
makes the buffer yours.

## Check it worked

Ten tests count requests at the stand-in rather than trusting the client's own bookkeeping, and
an eleventh checks that it answers 404 for an order named `constructor`. Two of them pin the
ordering argument.

```js title="oauth.test.mjs"
test('retry outside oauth: an attempt made after the token expired carries a fresh one', () => withApi({}, async (api, clock) => {
  const sdk = makeClient(api, clock, ['oauth', 'retry'])
  api.failNext({ status: 503, retryAfter: 30 }, { status: 503, retryAfter: 30 })
  const orders = await sdk.Order().list()
  assert.equal(orders.length, 2)
  assert.equal(clock.elapsed(), 60, 'two Retry-After waits moved the clock past the lifetime')
  assert.equal(api.state.requests, 3, 'three attempts')
  assert.equal(api.state.unauthorized, 0, 'the third attempt carried a fresh token')
  assert.equal(api.state.tokenRequests, 2)
}))

test('oauth outside retry: the retried attempt carries the stale token and is refused', () => withApi({}, async (api, clock) => {
  const sdk = makeClient(api, clock, ['retry', 'oauth'])
  api.failNext({ status: 503, retryAfter: 30 }, { status: 503, retryAfter: 30 })
  const orders = await sdk.Order().list()
  assert.equal(orders.length, 2, 'the call still succeeds')
  assert.equal(api.state.requests, 4, 'one more request than the other order')
  assert.equal(api.state.unauthorized, 1, 'a 401 went over the wire first')
  assert.equal(api.state.tokenRequests, 2)
}))
```

```bash
node --test oauth.test.mjs
```

```text output
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 832.237025
```

The test to watch is the late 401: two concurrent calls are refused, one of them answered late,
and one replacement serves both. Without the `used === this._token.value` check in `_withToken`,
the late handler throws away the token its sibling bought.

## When it goes wrong

Every call buys a token, with no error anywhere. Either the margin exceeds the lifetime, so each
token is spent on arrival, or the token endpoint omits `expires_in` and the client keeps the
token until a 401 says it is spent.

```bash
node pitfall.mjs
```

```text output
margin 90 s on a 60 s token: every token arrives already spent
  20 calls                        token requests 20   refused 0
no expires_in in the token response: a 401 is the only clock the client has
  18 calls over three minutes     token requests 3   refused 2   renewals 2
```

Twenty token requests for twenty calls is the first shape; its fix is a margin shorter than the
lifetime the endpoint grants. The second costs one refused request per lifetime, and its fix
belongs at the issuer, which
[RFC 6749](https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1) asks to send `expires_in`
or document the lifetime.

The same symptom in Go has a different cause. The package's default buffer expires any token
that lives ten seconds or less on arrival, as the middle line of the Go output shows, and
`ReuseTokenSourceWithExpiry` is the fix.

The call fails with `token endpoint answered 503` and the API saw no request. A thrown error is
retryable to the `retry` feature outside, so the token request was tried three times before the
failure reached the caller.

## When not to do this

Do not write an sdkgen feature for an issuer whose exchange is a refresh token posted as JSON.
The shipped `secrets` feature does that, with a single in-flight purchase, and it is already
ported to twenty targets.

Do not set the generated `apikey` option as well. The client sends it as a bearer token and the
wrapper overwrites it on every attempt, so the `apikey` never reaches the API.

Do not use the grant for a user's data. RFC 6749 restricts client credentials to resources under
the client's own control or arranged in advance, and a token with no user in it loses per-user
audit and consent.

Do not put the renewal in a pipeline hook. A `PreRequest` hook runs once per operation, so a
retried attempt would carry the first attempt's token. The transport seam is the place, which is
why the feature declares `transport: 'wrap'`.

## Related how-tos

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

- [Choose the pipeline stage where an SDK behavior should run](/howto/map-a-behaviour-to-a-pipeline-stage)

- [Wrap an SDK transport in a middleware chain](/howto/wrap-an-sdk-transport-with-middleware)

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

- [Get a client credentials token for calls between services](/howto/client-credentials-for-service-to-service-calls)

## Last verified

Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, `@voxgig/create-sdkgen` 0.30.8,
`@voxgig/sdkgen` 4.35.0, and `golang.org/x/oauth2` v0.35.0. Every output block is what the
command preceding it printed. The authorization server and the API are the stand-in in
`server.mjs`, on the loopback interface, with a clock both sides share. Speakeasy and OpenAPI
Generator were not run; their behavior is read from the generated hook, the template, and the
documentation this page links.

[^1]: Section 2.3.1 of [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749.html#section-2.3.1)
says the authorization server MUST support HTTP Basic, and that credentials in the request body
are NOT RECOMMENDED. The method the specification discourages
is the one Speakeasy's hook sends by default. The Go package's
[comment](https://pkg.go.dev/golang.org/x/oauth2#AuthStyle) calls Basic "an optional style
described in the OAuth2 RFC 6749 section 2.3.1" and reads the section from the client's side,
where the word is MAY.

[^2]: Three buffers appear on this page. The Go package subtracts ten seconds, in a comment in
[`token.go`](https://github.com/golang/oauth2/blob/master/token.go) that explains the
subtraction as a guard against client-server time mismatches. The Speakeasy
[session store](https://github.com/formancehq/formance-sdk-typescript/blob/main/src/lib/oauth2-sessions.ts)
subtracts sixty, and its comment reads "A 60-second buffer is applied to refresh tokens before
they actually expire" though the check covers every session. The feature on this page subtracts
ten, because that figure arrived with a reason.

[^3]: The Java interceptor imports `org.apache.oltu.oauth2` for its token request.
[Apache Oltu](https://attic.apache.org/projects/oltu.html) was retired in March 2018 and moved
to the Attic, the foundation's shelf for projects at the end of their life, that July. The
[template](https://github.com/OpenAPITools/openapi-generator/blob/master/modules/openapi-generator/src/main/resources/Java/libraries/okhttp-gson/auth/RetryingOAuth.mustache)
on the generator's main branch imports it still, so a Java client generated with OAuth support
depends on a library its own foundation closed in 2018.