How-to › Authenticate and authorize calls

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.

Audience
API consumer
Level
advanced
Topic
Run OAuth 2.1 and OIDC flows
Languages
TypeScript, JavaScript and Go
Verified

Read first: Get a client credentials token for calls between services

Your service calls a partner API through a generated SDK, and the machine token is your application’s problem. A helper fetches it at startup, a scheduled job renews it, and three services copied the helper with three different expiry checks. One copy renews on every call, so the authorization server rate-limits your client before the API ever does.

What you get

You will end up with a generated client that fetches its machine token, reuses it until a margin before expiry, and renews it inside the retry loop. A test counts the token requests. This is for you if application code mints the SDK’s token.

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; the sample’s token endpoint takes client_secret_basic, the client authentication the RFC requires every server to support.1

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Hand-written transport wrapperAny client that takes an HTTP client or a fetch function, in a language with a library such as golang.org/x/oauth2A wrapper per language and per client to keep in step, and an expiry rule you ownThe generator already fetches the token for every language you ship
OpenAPI GeneratorA Java client on the default okhttp-gson library, whose RetryingOAuth interceptor runs the grantRenewal only after a 401 or 403, with expires_in never read, so every token lifetime ends with one refused requestYou generate another language, or one refused request per token lifetime is too many
sdkgen custom featureAn sdkgen project where the token policy should be yours and should run at the transport seamA model file and a source file per target, written and tested by you, because no shipped feature runs this grantThe generator you use already manages the token, or you ship many sdkgen targets
SpeakeasyA Speakeasy SDK whose document declares oauth2 with clientCredentials and a tokenUrlA fixed policy: client_secret_post by default, a sixty-second expiry buffer, and a 401 that fails the call before the next one renewsYou 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 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.

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 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.

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.

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>.

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.

const FEATURE_CLASS = {
    oauth: OauthFeature_1.OauthFeature,
    retry: RetryFeature_1.RetryFeature,
    test: TestFeature_1.TestFeature,
};

Put retry outside the token

The feature catalogue 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.

// ['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.

node demo.mjs
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, sends client_secret_post by default and keeps one session per client id, secret, and scope set. Its session store 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, 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 returns an http.Client whose transport adds and renews the token, and any SDK that accepts an *http.Client inherits it.

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)
go run -C go .
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 that its comment attributes to client-server time mismatches. A five-second token is therefore spent on arrival, and every call buys another; 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.

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)
}))
node --test oauth.test.mjs
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.

node pitfall.mjs
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 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'.

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.

Footnotes

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

  2. Three buffers appear on this page. The Go package subtracts ten seconds, in a comment in token.go that explains the subtraction as a guard against client-server time mismatches. The Speakeasy session store 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. ↩︎ Back to text

  3. The Java interceptor imports org.apache.oltu.oauth2 for its token request. Apache Oltu 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 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. ↩︎ 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.