|
| 1 | +# Trusted Publishers |
| 2 | + |
| 3 | +**Push to the Hub from CI without storing an `HF_TOKEN` secret.** |
| 4 | + |
| 5 | +Your CI job proves its identity to Hugging Face using a short-lived OpenID Connect (OIDC) token from your CI provider, and gets back a short-lived Hugging Face token in exchange. No HF token to store as a secret or rotate. |
| 6 | + |
| 7 | +| | Personal Access Token | Trusted Publisher | |
| 8 | +| --- | --- | --- | |
| 9 | +| Lifetime | Until revoked | 1 hour | |
| 10 | +| Storage | CI secret | Nothing to store | |
| 11 | +| Rotation | Manual | Automatic, every run | |
| 12 | +| If leaked | Valid until you revoke | At most ~1 hour, and scoped to one repo | |
| 13 | + |
| 14 | +Trusted Publishers are the same idea as PyPI's [Trusted Publishers](https://docs.pypi.org/trusted-publishers/) and [npm's Trusted Publishing](https://docs.npmjs.com/trusted-publishers). |
| 15 | + |
| 16 | +## Quick example: publish a model from GitHub Actions |
| 17 | + |
| 18 | +You maintain `acme/awesome-model` on the HF Hub and want CI in an externally-hosted `acme/awesome-model-training` repository (on GitHub, GitLab, or any OIDC-compliant provider) to push new checkpoints — no `HF_TOKEN` secret. |
| 19 | + |
| 20 | +### 1. Configure the trusted publisher on the Hub |
| 21 | + |
| 22 | +On `https://huggingface.co/acme/awesome-model/settings`, open **Trusted Publishers** and add: |
| 23 | + |
| 24 | +- **Provider**: GitHub Actions |
| 25 | +- **Claims** (all must match for the exchange to succeed): |
| 26 | + - `repository` = `acme/awesome-model-training` |
| 27 | + - `branch` = `main` |
| 28 | + - `workflow` = `publish.yml` |
| 29 | + |
| 30 | +> [!TIP] |
| 31 | +> `repository` alone scopes the publisher to a GitHub repo. Add `ref` and/or `workflow` to also pin it to a branch or workflow file — recommended. |
| 32 | +
|
| 33 | +### 2. Add the workflow to your GitHub repo |
| 34 | + |
| 35 | +`.github/workflows/publish.yml`: |
| 36 | + |
| 37 | +```yaml |
| 38 | +name: Publish to Hugging Face |
| 39 | + |
| 40 | +on: |
| 41 | + push: |
| 42 | + branches: [main] |
| 43 | + workflow_dispatch: |
| 44 | + |
| 45 | +jobs: |
| 46 | + publish: |
| 47 | + runs-on: ubuntu-latest |
| 48 | + |
| 49 | + permissions: |
| 50 | + id-token: write # required so the job can request an OIDC token |
| 51 | + contents: read |
| 52 | + |
| 53 | + steps: |
| 54 | + - uses: actions/checkout@v4 |
| 55 | + - uses: actions/setup-python@v5 |
| 56 | + with: |
| 57 | + python-version: "3.11" |
| 58 | + - run: pip install --upgrade "huggingface_hub>=1.18.0" |
| 59 | + |
| 60 | + - name: Exchange GitHub OIDC token for a Hub token |
| 61 | + run: | |
| 62 | + set -euo pipefail |
| 63 | +
|
| 64 | + # The HF repo to publish to. For non-model repos, prefix accordingly: |
| 65 | + # datasets/acme/awesome-dataset, spaces/acme/awesome-space, kernels/acme/awesome-kernel |
| 66 | + RESOURCE="acme/awesome-model" |
| 67 | +
|
| 68 | + ID_TOKEN=$(curl -sSf \ |
| 69 | + -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ |
| 70 | + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://huggingface.co" \ |
| 71 | + | jq -r .value) |
| 72 | +
|
| 73 | + HEADERS=$(mktemp); BODY=$(mktemp) |
| 74 | + STATUS=$(curl -sS -D "$HEADERS" -o "$BODY" -w '%{http_code}' \ |
| 75 | + -X POST "https://huggingface.co/oauth/token" \ |
| 76 | + -H "Content-Type: application/json" \ |
| 77 | + -d "$(jq -n --arg t "$ID_TOKEN" --arg r "$RESOURCE" \ |
| 78 | + '{ |
| 79 | + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", |
| 80 | + subject_token_type: "urn:ietf:params:oauth:token-type:id_token", |
| 81 | + subject_token: $t, |
| 82 | + resource: $r |
| 83 | + }')") |
| 84 | +
|
| 85 | + if [ "$STATUS" -ne 200 ]; then |
| 86 | + REQUEST_ID=$(grep -i '^x-request-id:' "$HEADERS" | sed 's/.*: *//' | tr -d '\r') |
| 87 | + echo "::error::Token exchange failed with HTTP $STATUS (x-request-id: ${REQUEST_ID:-unknown})" |
| 88 | + cat "$BODY" |
| 89 | + exit 1 |
| 90 | + fi |
| 91 | +
|
| 92 | + HF_TOKEN=$(jq -r .access_token "$BODY") |
| 93 | + echo "::add-mask::$HF_TOKEN" |
| 94 | + # Safe way to write to env |
| 95 | + DELIM="EOF_$(openssl rand -hex 16)" |
| 96 | + { |
| 97 | + echo "HF_TOKEN<<$DELIM" |
| 98 | + echo "$HF_TOKEN" |
| 99 | + echo "$DELIM" |
| 100 | + } >> "$GITHUB_ENV" |
| 101 | +
|
| 102 | + - name: Upload checkpoint |
| 103 | + run: | |
| 104 | + hf upload acme/awesome-model ./checkpoint . \ |
| 105 | + --commit-message "Publish from ${GITHUB_SHA::7}" |
| 106 | +``` |
| 107 | +
|
| 108 | +That's it — `huggingface_hub` picks up `HF_TOKEN` from the environment. |
| 109 | + |
| 110 | +Complete working examples, both publishing to [`coyotte508/published-from-github`](https://huggingface.co/coyotte508/published-from-github): |
| 111 | + |
| 112 | +- GitHub Actions — [`github.com/coyotte508/publish-to-hf`](https://github.com/coyotte508/publish-to-hf) |
| 113 | +- GitLab CI — [`gitlab.com/coyotte508/publish-to-hf`](https://gitlab.com/coyotte508/publish-to-hf) |
| 114 | + |
| 115 | +## Two flavors: repo vs. user |
| 116 | + |
| 117 | +| Flavor | Configured on | What you get | Use it to… | |
| 118 | +| --- | --- | --- | --- | |
| 119 | +| **Repo publisher** | A repo's **Settings → Trusted Publishers** | A token with **write access to that one repo** | Publish a model, dataset, Space, or kernel from CI | |
| 120 | +| **User publisher** | Your account's [**Authentication settings → CI/CD Access**](https://huggingface.co/settings/authentication#ci-cd-access) | A read-only token with the `gated-repos` scope | Read **your** gated repos and use your rate limits from CI | |
| 121 | + |
| 122 | +Both tokens expire after 60 minutes. You need the **Write** role on a Hub repo to manage its trusted publishers. |
| 123 | + |
| 124 | +### Accessing gated repos from CI |
| 125 | + |
| 126 | +If you only need to *read* gated repos (e.g. download a model from a job), configure a publisher on your account under [**Authentication settings → CI/CD Access**](https://huggingface.co/settings/authentication#ci-cd-access) instead of on a specific repo, then pass your **username** as `resource`: |
| 127 | + |
| 128 | +```bash |
| 129 | +HF_TOKEN=$(curl -sSf -X POST "https://huggingface.co/oauth/token" \ |
| 130 | + -H "Content-Type: application/json" \ |
| 131 | + -d "$(jq -n --arg t "$ID_TOKEN" --arg r "your-hf-username" \ |
| 132 | + '{ |
| 133 | + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", |
| 134 | + subject_token_type: "urn:ietf:params:oauth:token-type:id_token", |
| 135 | + subject_token: $t, |
| 136 | + resource: $r |
| 137 | + }')" \ |
| 138 | + | jq -r .access_token) |
| 139 | +``` |
| 140 | + |
| 141 | +The resulting token can read your gated repos and uses your account's rate limits. It **cannot** write anything, and **cannot** read your private repos. |
| 142 | + |
| 143 | +## Supported CI providers |
| 144 | + |
| 145 | +The settings UI ships with presets for the providers below, but any OIDC-compliant provider works (AWS, GCP, Buildkite, your own IdP, …). |
| 146 | + |
| 147 | +| Provider | Issuer | How to get the ID token | |
| 148 | +| --- | --- | --- | |
| 149 | +| **GitHub Actions** | `https://token.actions.githubusercontent.com` | Set `permissions: id-token: write`, then call the metadata endpoint with `audience=https://huggingface.co`. [Docs](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect). | |
| 150 | +| **GitLab CI** | `https://gitlab.com` (or your self-hosted URL) | Declare `id_tokens: { HF_ID_TOKEN: { aud: https://huggingface.co } }` on the job; read `$HF_ID_TOKEN`. [Docs](https://docs.gitlab.com/ci/yaml/#id_tokens). | |
| 151 | +| **CircleCI** | `https://oidc.circleci.com/org/<org-uuid>` | Use `$CIRCLE_OIDC_TOKEN_V2` (v2 lets you set the audience in project settings). [Docs](https://circleci.com/docs/openid-connect-tokens/). | |
| 152 | +| **Bitbucket Pipelines** | `https://api.bitbucket.org/2.0/workspaces/<workspace>/pipelines-config/identity/oidc` | Set `oidc: true` on the step; read `$BITBUCKET_STEP_OIDC_TOKEN`. [Docs](https://support.atlassian.com/bitbucket-cloud/docs/integrate-pipelines-with-resource-servers-using-oidc/). | |
| 153 | + |
| 154 | +Once you have the ID token, the exchange call is identical across providers — only the **claims** you configure on the Hub side differ. |
| 155 | + |
| 156 | +## How it works |
| 157 | + |
| 158 | +1. Your CI provider mints a short-lived **OIDC ID token** describing the job (which repo, which branch, which workflow, …). |
| 159 | +2. Your workflow `POST`s that token to `https://huggingface.co/oauth/token`, along with a `resource` (the repo or username it wants to access). |
| 160 | +3. The Hub checks the token's signature and claims against the publishers you configured for that resource, and returns a Hugging Face token. |
| 161 | + |
| 162 | +``` |
| 163 | +┌──────────┐ 1. mint ID token ┌──────────┐ 2. exchange ┌────────────┐ |
| 164 | +│ CI job │ ─────────────────▶ │ CI OIDC │ ──────────────▶│ huggingface│ |
| 165 | +│ │ │ issuer │ │ /oauth/ │ |
| 166 | +│ │ ◀──────────────────────────────────────────────│ token │ |
| 167 | +└──────────┘ 3. short-lived HF token (valid 1 h) └────────────┘ |
| 168 | +``` |
| 169 | +
|
| 170 | +## API reference |
| 171 | +
|
| 172 | +<details> |
| 173 | +<summary><strong>Endpoint, request, and response</strong></summary> |
| 174 | +
|
| 175 | +**Endpoint:** `POST https://huggingface.co/oauth/token` with `Content-Type: application/json`. |
| 176 | +
|
| 177 | +No client authentication is needed — the OIDC ID token authenticates the request. The exchange follows [RFC 8693 — OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693.html). |
| 178 | +
|
| 179 | +**Request body:** |
| 180 | +
|
| 181 | +| Field | Required | Value | |
| 182 | +| --- | --- | --- | |
| 183 | +| `grant_type` | yes | `urn:ietf:params:oauth:grant-type:token-exchange` | |
| 184 | +| `subject_token_type` | yes | `urn:ietf:params:oauth:token-type:id_token` | |
| 185 | +| `subject_token` | yes | The raw OIDC ID token (JWT) from your CI provider. Its `aud` claim **must** be `https://huggingface.co`. | |
| 186 | +| `resource` | yes | A Hub repo (`namespace/name`, `datasets/namespace/name`, `spaces/namespace/name`, `kernels/namespace/name`) or a Hub **username** (no slash) for a user-scoped token. | |
| 187 | +
|
| 188 | +**Success response:** |
| 189 | +
|
| 190 | +```json |
| 191 | +{ |
| 192 | + "access_token": "hf_jwt_…", // "hf_oauth_…" for user resources |
| 193 | + "token_type": "bearer", |
| 194 | + "expires_in": 3600, |
| 195 | + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token" |
| 196 | +} |
| 197 | +``` |
| 198 | + |
| 199 | +**Errors** — `400 Bad Request` with an OAuth-style body: |
| 200 | + |
| 201 | +| `error` | Why | |
| 202 | +| --- | --- | |
| 203 | +| `invalid_request` | Missing/malformed parameter, or bad `resource` format. | |
| 204 | +| `invalid_grant` | Repo or user not found; no publisher matches this issuer; configured claims don't match; signature or audience check failed; account locked. | |
| 205 | + |
| 206 | +</details> |
| 207 | + |
| 208 | +## Security model |
| 209 | + |
| 210 | +- **Tokens are short-lived.** 60 minutes from the moment of exchange — the clock only starts when you call the endpoint, not when the workflow starts. There's no refresh token; long jobs should re-exchange. |
| 211 | +- **Repo tokens are repo-scoped.** A token for `acme/awesome-model` cannot touch `acme/anything-else`. Pushes are attributed to a synthetic `[OIDC]` system user, with a reference to the originating issuer and subject. |
| 212 | +- **User tokens are read-only.** Only the `gated-repos` scope — no writes, no private repos, no account management. |
| 213 | +- **Claims are matched exactly.** No regex, no prefix matching. `ref = refs/heads/main` will reject `refs/heads/main-backup`. |
| 214 | +- **Audit logs.** Adding or removing a publisher is logged, and successful exchanges update `lastUsedAt`. For security, error descriptions don't reveal which specific check failed. |
| 215 | + |
| 216 | +## See also |
| 217 | + |
| 218 | +- [User Access Tokens](./security-tokens) — the right choice for humans and one-off scripts |
| 219 | +- [OAuth / Sign in with HF](./oauth) — the same `/oauth/token` endpoint, used for interactive flows |
| 220 | +- [Managing Spaces with GitHub Actions](./spaces-github-actions) |
| 221 | +- [GitHub Actions integration for the Hub](./repositories-github-actions) |
0 commit comments