OAuth token exchange
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 └──────────────┘
- Sign a JWT assertion (RFC 7523) with your RSA or ECDSA private key.
-
Exchange the assertion at
POST /oauth/tokenfor a short-lived Buildkite API token (prefixedbktx_). -
Call the Buildkite REST or GraphQL API with the token in the
Authorization: Bearerheader. - 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:
- 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
scopeparameter. - 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 |