OAuth token exchange

Public preview feature

OAuth Token Exchange is currently in public preview and is not yet generally available.

OAuth Token Exchange lets you mint short-lived Buildkite API access tokens associated with a user in your organization programmatically, without interactive login flows. Your application signs a JWT assertion with its private key, exchanges it at the token endpoint for a scoped API token, and uses that token to call the Buildkite REST or GraphQL API on behalf of a user.

OAuth Token Exchange is ideal for security-conscious workflows where a central service mints tokens on behalf of users, avoiding long-lived tokens stored on individual machines. Common use cases include:

  • Centralized token authority: A single service issues short-lived tokens from a restricted IP range, reducing the attack surface compared to distributing long-lived API tokens across machines.
  • Non-interactive automation: Server-side integrations, CI/CD orchestration, and automated tooling that need to act as a Buildkite user without interactive login flows.
  • Least-privilege access: Each token is scoped to only the permissions required and expires automatically, limiting the potential blast radius of a compromised credential.

How it works

┌────────────┐    1. JWT assertion    ┌──────────────┐
│            │ ─────────────────────▶ │              │
│  Your app  │                        │ Buildkite    │
│            │ ◀───────────────────── │ /oauth/token │
│            │    2. bktx_ token      │              │
│            │                        └──────────────┘
│            │    3. Bearer bktx_...  ┌──────────────┐
│            │ ─────────────────────▶ │ Buildkite    │
│            │ ◀───────────────────── │ REST/GraphQL │
└────────────┘    4. API response     └──────────────┘
  1. Sign a JWT assertion (RFC 7523) with your RSA or ECDSA private key.
  2. Exchange the assertion at POST /oauth/token for a short-lived Buildkite API token (prefixed bktx_).
  3. Call the Buildkite REST or GraphQL API with the token in the Authorization: Bearer header.
  4. Cache the token in memory and refresh it before expiry. Do not exchange a new token for every request to prevent exhaustion of your token request rate-limits.

Setup

To use OAuth Token Exchange, you need:

  1. A Token Exchange application configured on the Buildkite's side. Provide the following details for configuration:
  • Name: A display name for the application.
  • Description: A description of the application.
  • JWKS: Your application's public key in JWKS format, provided as inline JSON or an https:// URI (see Provide your public key as a JWKS).
  • Grantable scopes: The scopes that can be set on minted access tokens.
  • Default scopes: The scopes set on minted access tokens when a token exchange request omits the scope parameter.
  • Allowed IP addresses (optional, recommended): Restrict token usage to specific IP addresses.
  • Maximum token TTL (optional): The maximum token lifetime in seconds. Defaults to 3600 (one hour).

After configuration, you will be provided with a client ID for your application.

Generate a key pair

Generate an RSA or ECDSA key pair. The private key stays with your application. The public key is registered with Buildkite as a JWKS.

RSA (2048-bit):

openssl genrsa -out private_key.pem 2048
openssl rsa -in private_key.pem -pubout -out public_key.pem

ECDSA (P-256):

openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem
openssl ec -in private_key.pem -pubout -out public_key.pem

Provide your public key as a JWKS

Buildkite requires your public key in JWKS format — a JSON object containing a keys array with one or more JWK entries. You can provide it as inline JSON or as an https:// URI that serves the JWKS.

If your identity provider publishes a JWKS endpoint, use that directly. If you have a PEM-encoded public key, convert it to JWKS using a standards-compliant library in your language.

Every key in your JWKS must include a kid (Key ID). Setting the matching kid in your JWT header allows Buildkite to look up the correct key directly. If your JWT omits kid, Buildkite tries all keys in the JWKS to verify the signature.

RSA key example:

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "my-key-1",
      "use": "sig",
      "alg": "RS256",
      "n": "<base64url-encoded modulus>",
      "e": "AQAB"
    }
  ]
}

EC key example:

{
  "keys": [
    {
      "kty": "EC",
      "kid": "my-key-1",
      "use": "sig",
      "alg": "ES256",
      "crv": "P-256",
      "x": "<base64url-encoded x coordinate>",
      "y": "<base64url-encoded y coordinate>"
    }
  ]
}

All key component values (n, e, x, y) must be base64url-encoded.

If you provide your JWKS using an HTTPS URI, Buildkite caches it for up to 1 hour. During key rotation, publish both old and new keys together for at least the cache duration.

Token exchange request

Exchange a signed JWT assertion for a Buildkite API token by sending a POST request to the token endpoint:

Endpoint: POST https://buildkite.com/oauth/token

POST /oauth/token HTTP/1.1
Host: buildkite.com
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOi...
&subject_token=user@example.com
&subject_token_type=urn:buildkite:params:oauth:token-type:user-email
&audience=your-org-slug
&scope=read_pipelines read_builds

Request parameters

Parameter Required Description
grant_type Yes Must be urn:ietf:params:oauth:grant-type:token-exchange
client_assertion_type Yes Must be urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion Yes A signed JWT assertion (see JWT assertion claims)
subject_token Yes The email address of the Buildkite user to act as
subject_token_type Yes Must be urn:buildkite:params:oauth:token-type:user-email
audience Yes The Buildkite organization slug (from the URL, not the display name)
scope No Space-delimited list of scopes. If omitted, the app's default scopes are used. If the app has no default scopes, omitting this parameter returns an error.
expires_in No Requested token TTL in seconds. Capped by the app's maximum TTL. Defaults to the app's maximum TTL if omitted.

The subject user must be an active member of the target organization with a verified email address.

The request audience parameter and the JWT aud claim are different values:

- audience (form parameter) is the Buildkite organization slug (for example, my-org)

- aud (JWT claim) is the token endpoint URL (https://buildkite.com/oauth/token)

Response

A successful response returns a JSON object:

{
  "access_token": "bktx_...",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "read_pipelines read_builds"
}
Field Description
access_token The minted API token, prefixed with bktx_
issued_token_type Always urn:ietf:params:oauth:token-type:access_token
token_type Always Bearer
expires_in Token lifetime in seconds
scope The scopes granted to the token

Use the access_token value in the Authorization header for API requests:

Authorization: Bearer bktx_...

JWT assertion claims

The client_assertion is a JWT (RFC 7523) signed with your application's private key. It must contain these claims:

Claim Value
iss Your application's client ID
sub Your application's client ID (must match iss)
aud The token endpoint URL (https://buildkite.com/oauth/token)
iat Issued-at timestamp (Unix epoch seconds)
exp Expiration timestamp — must be within 5 minutes of iat

Optional claims:

Claim Value
jti A unique identifier for the JWT (RFC 7519 §4.1.7). If present, must be a non-empty string of at most 255 bytes. A UUID is a common format. After a successful token exchange, Buildkite rejects later requests that reuse the same jti. By default, JWTs without a jti are accepted.
nbf Not-before timestamp. If set, must not be in the future.

Supported signing algorithms: RS256 (RSA) and ES256 (ECDSA P-256).

Example JWT header

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "my-key-1"
}

Example JWT payload

{
  "iss": "0123456789abcdef0123",
  "sub": "0123456789abcdef0123",
  "aud": "https://buildkite.com/oauth/token",
  "iat": 1710849600,
  "exp": 1710849900,
  "jti": "550e8400-e29b-41d4-a716-446655440000"
}

Available scopes

Token exchange tokens support the same scopes as Buildkite API access tokens. The scopes granted to a token are limited to the application's configured grantable scopes.

Best practices

Follow these recommendations to keep your OAuth Token Exchange integration secure and reliable.

Cache and reuse tokens

Do not exchange a new token for every API request. This creates unnecessary load on the authentication infrastructure.

Instead, cache the token in memory and reuse it across requests. When the token is close to expiry, refresh it by performing another exchange.

Thread safety

If your application makes concurrent API calls, ensure your token cache is protected with a mutex or equivalent synchronization primitive.

Minimize scopes

Request only the scopes your application needs. Use the scope parameter to request a subset of the app's grantable scopes, rather than relying on the app's full default scopes.

Include a JTI claim

Include a unique jti (JWT ID) claim in each assertion to reduce replay risk. Use a UUID or another unique value for each request. After a successful token exchange, Buildkite rejects later requests that reuse the same jti.

Use short TTLs

Prefer shorter token lifetimes to limit the blast radius if a token is compromised. The expires_in parameter lets you request a TTL shorter than the app's maximum.

Example client

The buildkite-token-exchange-example repository contains a complete Go client that demonstrates the full token exchange flow, including JWT signing, token caching, and API calls.

Troubleshooting

The token endpoint returns RFC 6749 §5.2 error responses with an error code and optional error_description:

{
  "error": "invalid_client",
  "error_description": "Invalid client assertion signature"
}

Common errors

error error_description Fix
invalid_client "Invalid client assertion signature" Check that the public key in your JWKS matches the private key used to sign the JWT
invalid_client "JWT aud claim is invalid" Set the JWT aud claim to https://buildkite.com/oauth/token
invalid_client "JWT exp claim must be in the future" Check your system clock for skew
invalid_client "JWT has already been used (jti)" The jti has already been consumed. Generate a new unique jti for each request
invalid_client "JWT must contain a `jti` claim" Your organization requires a jti claim. Add a unique jti (for example, a UUID) to your JWT payload
invalid_request "Subject user must be an active member of the organization" Verify the email address belongs to a member of the target organization
invalid_scope "Requested scopes exceed grantable scopes" Only request scopes that are in the app's configured grantable scopes
invalid_target "Invalid audience organization" Use the organization slug from the URL, not the display name
unsupported_grant_type "Token exchange is not enabled for this organization" The organization must be enrolled in the public preview