Trust credentials#
A trust credential lets a CI job authenticate as a bot user by presenting a token signed by its own platform, instead of holding a long-lived BSR token. The BSR verifies that token, matches its subject/claims against the trust credentials configured on the bot user, and returns a short-lived API token that acts as that account.
This means that you don’t need to configure long-lived API tokens in your CI pipelines. The Buf GitHub Action performs the exchange for you, so a workflow only has to name the bot user it authenticates as.
Trust credentials are configured on bot users, so they’re available on self-hosted and dedicated BSR instances.
Create a trust credential#
Only BSR administrators can manage trust credentials, and only on bot users. Create the bot user first, and grant it the roles it needs.
-
Go to the admin panel for your BSR instance and select Bot Users in the left-side menu. For example, if your BSR is
https://buf.example.com, the page is athttps://buf.example.com/admin/bot-users. -
Click the bot user’s name to open its details page, and find the Trust credentials section.
-
Click Create trust credential and fill in the fields below.
A bot user can hold up to 50 trust credentials. The exchange accepts a token when any one of them matches, so you can use a separate credential per workload rather than widening a single one.
Fields#
| Field | Description |
|---|---|
| Name | Identifies the credential in the admin UI and in the audit log. Unique per bot user. |
| Issuer | The platform that signs the workload token. GitHub Actions is built in; choose Custom for anything else. |
| Issuer URL | Custom issuers only. Must equal the token’s iss claim exactly, including any trailing slash, and must be https. |
| Subject | Pattern the token’s sub claim must match. |
| Custom claims | Additional claims the token must also match, as key-value pairs. Optional. |
| Access | What tokens minted by this credential may do: full access, or a limited set of scoped permissions. |
Patterns in Subject and Custom claims are simple globs: * matches any run of characters, including an empty one, and every other character must match exactly.
The whole value has to match, so repo:acme/protos:* matches every workflow in acme/protos and nothing else.
The BSR rejects a pattern made only of wildcards, since it would match every value.
A credential’s Access can only narrow what the bot user can already do. The BSR intersects the scoped permissions with the bot user’s live permissions on every request, so a credential can never grant more than the account it acts as.
Claim matching#
The BSR matches the flattened claims of the verified token:
- Nested objects flatten to dotted paths, so
{"acme": {"tier": "gold"}}is matched asacme.tier. - Numbers and booleans are compared as their string form:
repository_id42matches the pattern42. - Arrays and
nullvalues don’t match anything, because an exact match over them is ambiguous. iss,aud,exp,nbf,iat, andjtican’t be used as custom claims. The BSR enforces them itself as part of verification, so a rule on them would be meaningless.subis matched through the Subject field.
Example: GitHub Actions#
GitHub’s sub names the repository, plus the ref, environment, or pull request event the job runs for.
The repository, ref, workflow, and event appear as separate claims too, so how narrow you need the rule decides which form to match on.
Subject formats#
GitHub emits one of two subject formats, and which one your repository uses decides what a Subject pattern has to look like:
| Format | Example sub |
|---|---|
| Name-based | repo:acme/protos:ref:refs/heads/main |
| Immutable | repo:acme@123456/protos@456789:ref:refs/heads/main |
The immutable format embeds the numeric owner and repository IDs, so a credential keeps naming the same repository after a rename, and a recycled acme/protos can’t inherit its trust.
Every repository created after July 15, 2026 uses it, as does any repository renamed or transferred after that date.
Older repositories keep the name-based format until they opt in, and GitHub Enterprise Server isn’t part of the rollout.
Check which format a repository emits, and opt in to the immutable one, at https://github.com/<owner>/<repo>/settings/actions/oidc-configuration, or through Settings > Actions > OIDC Configuration.
See GitHub’s OIDC token reference for the full claim list and the subject customization API.
Patterns#
The examples below use the immutable format. Substitute the name-based form for a repository that still emits it.
| Goal | Subject | Custom claims |
|---|---|---|
| Any workflow in one repository | repo:acme@123456/protos@456789:* |
none |
| Only the default branch | repo:acme@123456/protos@456789:ref:refs/heads/main |
none |
| Only a named environment | repo:acme@123456/protos@456789:environment:production |
none |
| Any release branch, pushes only | repo:acme@123456/protos@456789:* |
ref: refs/heads/release-*event_name: push |
| One reusable workflow, any caller in the org | repo:acme@123456/* |
job_workflow_ref: acme/ci/.github/workflows/push.yaml@refs/heads/main |
To write a credential that survives either format, identify the repository through custom claims instead, and leave the subject to narrow the ref:
| Field | Value |
|---|---|
| Subject | repo:*:ref:refs/heads/main |
| Custom claims | repository_owner_id: 123456repository_id: 456789 |
repository_id and repository_owner_id are separate claims that the subject format doesn’t affect, and unlike the names they can’t be recycled.
A subject of repo:* alone isn’t accepted, since a pattern has to restrict the claim.
GitHub Actions#
The Buf GitHub Action performs the exchange when you set bot_username instead of token.
The job needs id-token: write permission so GitHub signs a token for it.
name: Buf CI
on:
push:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
delete:
permissions:
contents: read
pull-requests: write
id-token: write # Required so GitHub mints an OIDC token for the job.
jobs:
buf:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: bufbuild/buf-action@v1
with:
# The bot user to authenticate as, instead of "token".
bot_username: my-bot-user
# The BSR to authenticate to. Defaults to buf.build.
domain: buf.example.com
The action masks the minted token in the logs, exposes it as the token output for later steps in the same job, and revokes it in its post step when the job ends.
Set either bot_username or token, never both.
A BUF_TOKEN in the job or workflow environment counts as a static token here, and the action fails rather than silently picking one.
Set id-token: write at workflow level, as above, or on the job.
A job that declares its own permissions block overrides the workflow’s, so it needs the permission there too.
Implement the exchange yourself#
Any platform that issues OIDC tokens to its workloads can use trust credentials.
It uses two form-encoded endpoints on your BSR host, neither of which needs a registered client.
src/federation.ts in the Buf GitHub Action is a complete reference implementation.
Mint a workload token#
Request an OIDC ID token from the platform with the audience https://<bsr-hostname>, matching the BSR you’re authenticating to.
The BSR requires:
- A signature the issuer publishes a key for, discovered at
<issuer>/.well-known/openid-configuration. The discovery and JWKS endpoints must be reachable from the BSR overhttps. - The token must be signed using any
RS256,RS384,RS512,ES256,ES384,ES512,PS256,PS384, orPS512. The BSR refuses HMAC algorithms. - An
issclaim equal to the credential’s issuer URL, byte for byte. - An
audclaim ofhttps://<bsr-hostname>. - An
iatno more than 15 minutes old, and anexpin the future. Mint the token immediately before the exchange rather than reusing one.
Exchange it for a BSR token#
$ curl https://buf.example.com/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
--data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:id_token" \
--data-urlencode "subject_token=${ID_TOKEN}" \
--data-urlencode "account=my-bot-user"
| Parameter | Value |
|---|---|
grant_type |
urn:ietf:params:oauth:grant-type:token-exchange |
subject_token |
The OIDC ID token minted for the workload. |
subject_token_type |
urn:ietf:params:oauth:token-type:id_token, the only accepted type. |
account |
Username of the bot user to act as. |
requested_token_type |
Optional. urn:ietf:params:oauth:token-type:access_token if set. |
Send the parameters in the request body, not the query string: a subject_token in a URL is retained by proxies and access logs.
The BSR rejects the RFC 8693 narrowing and delegation parameters (scope, resource, audience, actor_token, actor_token_type) rather than ignoring them, because the credential decides what a token may do, not the caller.
A successful exchange returns:
{
"access_token": "...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "bearer",
"expires_in": 3600
}
Use access_token as BUF_TOKEN.
It acts as the bot user for one hour.
There’s no refresh token: the lifetime is fixed, and a job that needs longer performs another exchange.
The minted token appears in the bot user’s token list with a note recording the issuer and workload it came from. Deleting the trust credential revokes every token it minted, so removing one mid-job cuts that job off, not just the next one.
Revoke the token#
When the job finishes, revoke its token with RFC 7009 revocation so it stops working immediately instead of at the end of its hour:
$ curl https://buf.example.com/oauth2/revoke \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "token=${BUF_TOKEN}"
A successful revocation returns 200 with an empty body, and so does revoking a token that’s unknown or already expired.
Possession of the token authorizes the call, so you don’t need any other credential.
The endpoint only revokes tokens minted by a trust credential; a static token returns unsupported_token_type, and you revoke that one from the admin panel.
Errors#
Errors use the standard OAuth envelope, {"error": "...", "error_description": "..."}.
error |
Meaning |
|---|---|
invalid_grant |
The exchange was refused. Uniform and deliberately unspecific. |
invalid_request |
A parameter is missing, repeated, or not one this grant accepts. |
unsupported_grant_type |
This BSR doesn’t serve the token-exchange grant. Check the instance version. |
slow_down |
Rate limited. The endpoint allows 30 requests per minute per client IP, with a burst of 10. |
Retry only slow_down and 5xx responses.
invalid_grant is a decision, not a transient failure, and repeating the request only slows down the diagnosis.
The BSR returns invalid_grant for every trust failure: an unknown bot user, an inactive one, a token that fails verification, and a token no credential matches.
The BSR doesn’t report which check failed, so that the endpoint can’t be used to enumerate which accounts, issuers, or credentials exist.
It records the reason in the instance’s server logs, where an administrator can read it.
Troubleshooting#
| Symptom | What to check |
|---|---|
invalid_grant |
That account names an active bot user, and that the bot user holds a trust credential for this issuer. Decode the workload token and check its claims against the credential’s subject and custom claim patterns. |
invalid_grant only from some jobs |
The credential is narrower than the set of jobs using it. Pull requests, tags, and reusable workflows produce different sub and ref values than a push to the default branch. |
invalid_grant right after a repository rename or transfer |
GitHub moved the repository to the immutable subject format. Update the credential’s subject, or match on repository_id instead. |
unsupported_grant_type |
The BSR predates trust credentials, or you’re pointed at a host that isn’t your BSR. |
invalid_request on aud or issuer |
The audience is https://<hostname> with no trailing slash and no path, and the issuer URL on the credential equals the token’s iss exactly. |
| The exchange succeeds but a command is denied | The bot user’s roles, or the credential’s Access scopes, don’t cover the operation. Scopes intersect with the account’s permissions, so both have to allow it. |
| Nothing works, and the instance is self-hosted | The issuer’s discovery and JWKS endpoints have to be reachable from the BSR. See On-prem BSR configuration. |
On GitHub Actions, rerun the workflow with debug logging enabled. The action then logs the claims GitHub put in the token, and the BSR’s response status and request ID for each attempt. The tokens themselves stay masked.