An API key kept in a repository's secrets ends up copied, leaked into a log, or outliving the person who created it. With OIDC, your continuous integration stores none: GitHub Actions, GitLab CI or any other OIDC issuer signs, for each job, a token describing the repository, the branch and the workflow, and FFxF accepts it in place of a key when it matches a binding you declared.
How it works
- A provider names a trusted issuer, by its URL. FFxF gives it
an audience unique to your account, of the form
ffxf-oidc-…. - A binding says which tokens from that issuer are accepted (conditions on their claims) and what they may do (scopes, the same as for keys).
- The job requests a token for your audience and sends it as a bearer token,
exactly like a key:
Authorization: Bearer <token>.
Such a token lives a few minutes (five on GitHub). Whoever finds it later can do nothing with it, and there is nothing to rotate.
1. Declare the provider
In the console, Account → OIDC, give a name and the issuer URL:
| Continuous integration | Issuer URL |
|---|---|
| GitHub Actions | https://token.actions.githubusercontent.com |
| GitLab.com | https://gitlab.com |
| Self-managed GitLab | your instance's address, over https:// |
Any issuer that publishes /.well-known/openid-configuration over
HTTPS works. The URL must be https:// on port 443, with no IP
address, credentials, query or fragment, and the discovery document must announce
exactly the same issuer. Ten providers per account, one per issuer.
The audience is copied from the console and never changes. It is not a secret the way a key is, since it cannot authenticate on its own, but do not publish it without reason: at a shared issuer such as GitHub, it is what ties a token to your account rather than another. To change it, delete the provider and create it again.
2. Write a binding
A binding brings together conditions on the token's claims, scopes and an optional expiry date. Every condition must match:
*stands for any sequence of characters,?for one; matching is case-sensitive and covers the whole value.- A claim holding a list matches when any item matches; a nested claim is named with a dot.
- A condition on
subis required, and it must name the owner before any wildcard:repo:acme/*is accepted,repo:*andrepo:acme*are refused (the latter would let theacme-evilaccount in).
The GitHub Actions sub, depending on what triggers the job:
| Trigger | sub |
|---|---|
| a branch | repo:acme/app:ref:refs/heads/main |
| a tag | repo:acme/app:ref:refs/tags/v1.4.0 |
| an environment | repo:acme/app:environment:production |
| a pull request | repo:acme/app:pull_request |
On GitLab it reads project_path:group/project:ref_type:branch:ref:main.
Add a second condition to pin the workflow file, for instance
workflow_ref =
acme/app/.github/workflows/deploy.yml@refs/heads/main: another job of
the same repository then gets nothing. Twenty bindings per provider, twenty
conditions per binding.
3. From GitHub Actions
The job needs the id-token: write permission. It requests its token
from GitHub for your audience, masks it in the log, then calls the API:
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
env:
FFXF_OIDC_AUDIENCE: ffxf-oidc-… # copied from the console
steps:
- name: FFxF token
run: |
token=$(curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=$FFXF_OIDC_AUDIENCE" | jq -r .value)
echo "::add-mask::$token"
echo "FFXF_TOKEN=$token" >> "$GITHUB_ENV"
- name: List machines
run: curl -sS https://api.ffxf.net/v1/vms -H "Authorization: Bearer $FFXF_TOKEN"
The token expires five minutes after it is issued. For a longer job, request a new one before each step that calls the API: the call to GitHub is instant.
4. From GitLab CI
GitLab hands the token straight to a variable:
deploy:
id_tokens:
FFXF_TOKEN:
aud: ffxf-oidc-…
script:
- curl -sS https://api.ffxf.net/v1/vms -H "Authorization: Bearer $FFXF_TOKEN"
What FFxF checks
- The token's issuer and audience point to one of your enabled providers;
otherwise nothing is fetched and the answer is
401. - The signature, against the keys the issuer publishes, cached for an hour and read again as soon as a token presents an unknown key. Only RS, ES and PS (256, 384, 512) are accepted.
issexactly, andaud,exp,iatandsubpresent; with several audiences,azpmust be yours.- The provider's enabled bindings, in order: the first whose conditions all match, that has not expired and that carries the call's scope, wins.
An accepted token then acts as a key of your account: same quota, same credit, same suspensions, verified e-mail address required. Rate buckets and idempotency keys are counted per binding. There is no address allowlist: the identity comes from the token, not from the calling address.
Refusals
| Code | HTTP | What happened |
|---|---|---|
invalid_oidc_token | 401 | token malformed, badly signed, expired, or requested for an audience none of your providers declares |
oidc_claim_mismatch | 403 | valid token, but no enabled binding matches its claims |
binding_expired | 403 | the matching binding is past its expiry date |
insufficient_scope | 403 | the binding matches but lacks the call's scope, named in details.required |
oidc_provider_unavailable | 503 | the issuer's discovery document or keys cannot be reached; the failure is remembered for five minutes |
invalid_oidc_token does not say why: the answer never reveals
whether an audience or a binding exists in another account.
With the MCP server
The MCP server relays the Authorization
header as is. An AI agent running inside a CI job can therefore use the job's
OIDC token instead of a key, with the binding's scopes and nothing more.
Good practice
- One binding per use: reading for pull requests, creating for the deployment
from
main, destroying for the one job that cleans up. - Pin
workflow_ref, or anenvironmentprotected by an approval rule, on top ofsub. - Give trial bindings an expiry date.
- To cut access at once, disable the binding or the provider: the next call is refused.