Try FFxF: $10 free with code TRY10, no credit card needed

API

OIDC: CI without an API key

Call the API from GitHub Actions or GitLab CI with an OIDC token that lasts minutes, no stored key: provider, binding, conditions, refusals.

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 integrationIssuer URL
GitHub Actionshttps://token.actions.githubusercontent.com
GitLab.comhttps://gitlab.com
Self-managed GitLabyour 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 sub is required, and it must name the owner before any wildcard: repo:acme/* is accepted, repo:* and repo:acme* are refused (the latter would let the acme-evil account in).

The GitHub Actions sub, depending on what triggers the job:

Triggersub
a branchrepo:acme/app:ref:refs/heads/main
a tagrepo:acme/app:ref:refs/tags/v1.4.0
an environmentrepo:acme/app:environment:production
a pull requestrepo: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:

yaml
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:

yaml
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.
  • iss exactly, and aud, exp, iat and sub present; with several audiences, azp must 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

CodeHTTPWhat happened
invalid_oidc_token401token malformed, badly signed, expired, or requested for an audience none of your providers declares
oidc_claim_mismatch403valid token, but no enabled binding matches its claims
binding_expired403the matching binding is past its expiry date
insufficient_scope403the binding matches but lacks the call's scope, named in details.required
oidc_provider_unavailable503the 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 an environment protected by an approval rule, on top of sub.
  • Give trial bindings an expiry date.
  • To cut access at once, disable the binding or the provider: the next call is refused.

Support & discussions

Technical questions, incident reports, or infrastructure discussions, the team is reachable on Discord, Telegram, X, Instagram, Reddit, and IRC.