Skip to content

Commit 02aa4cb

Browse files
coyotte508julien-c
andauthored
Add Trusted Publishers documentation for keyless CI/CD (#2506)
* Add Trusted Publishers documentation for keyless CI/CD * Simplify trusted publishers documentation * rewrite * Update huggingface_hub version and use hf CLI shorthand * Update trusted publishers documentation with settings URL * Update docs/hub/trusted-publishers.md Co-authored-by: Julien Chaumond <julien@huggingface.co> * Update trusted publisher example to use upload_folder API * Improve GitHub Actions token exchange error handling and security * Update trusted publishers examples with GitLab CI * Update trusted publishers documentation and example script * Clarify HF repo reference in trusted publishers docs * hf upload * Use anchor link --------- Co-authored-by: Julien Chaumond <julien@huggingface.co>
1 parent 9cf9bd9 commit 02aa4cb

5 files changed

Lines changed: 234 additions & 1 deletion

File tree

docs/hub/_toctree.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,8 @@
500500
sections:
501501
- local: security-tokens
502502
title: User Access Tokens
503+
- local: trusted-publishers
504+
title: Trusted Publishers (CI/CD)
503505
- local: security-2fa
504506
title: Two-Factor Authentication
505507
- local: security-git-ssh

docs/hub/oauth.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ The currently supported scopes are:
109109
- `read-collections`: Get read access to the user's personal collections.
110110
- `write-collections`: Get write/read access to the user's personal collections. Also grants collection creation and deletion.
111111
- `inference-api`: Get access to the [Inference Providers](https://huggingface.co/docs/inference-providers/index), you will be able to make inference requests on behalf of the user.
112-
- `jobs`: Run [jobs](https://huggingface.co/docs/huggingface_hub/main/en/guides/jobs)
112+
- `jobs`: Run [jobs](https://huggingface.co/docs/huggingface_hub/main/en/guides/jobs)
113113
- `webhooks`: Manage [webhooks](https://huggingface.co/docs/huggingface_hub/main/en/guides/webhooks)
114114
- `write-discussions`: Open discussions and Pull Requests on behalf of the user as well as interact with discussions (including reactions, posting/editing comments, closing discussions, ...). To open Pull Requests on private repos, you need to request the `read-repos` scope as well.
115115

@@ -151,6 +151,9 @@ Check out [our badges](https://huggingface.co/datasets/huggingface/badges#sign-i
151151
152152
Token Exchange allows organizations to programmatically issue access tokens for their members without requiring interactive user consent. This is particularly useful for building internal tools, automation pipelines, and enterprise integrations that need to access Hugging Face resources on behalf of organization members.
153153

154+
> [!TIP]
155+
> If you only need keyless authentication from a CI/CD workflow (GitHub Actions, GitLab CI, CircleCI, …) — without per-member token issuance — see [Trusted Publishers](./trusted-publishers), which also uses `/oauth/token` but takes an OIDC `id_token` minted by your CI provider as the subject token (no Enterprise plan required, no client credentials).
156+
154157
This feature implements [RFC 8693 - OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693.html), a standard protocol for token exchange scenarios.
155158

156159
### Use cases

docs/hub/repositories-github-actions.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
You can use [GitHub Actions](https://docs.github.com/en/actions) to automatically sync your GitHub repository to the Hugging Face Hub. The official [`huggingface/hub-sync`](https://github.com/marketplace/actions/sync-github-to-hugging-face-hub) action supports syncing **Models**, **Datasets**, and **Spaces**.
44

5+
> [!TIP]
6+
> For keyless publishing — no `HF_TOKEN` secret to store or rotate — see [Trusted Publishers](./trusted-publishers), which exchanges GitHub Actions' built-in OIDC token for a short-lived, repo-scoped Hub token at the start of each run.
7+
58
## Setup
69

710
1. Create a Hugging Face [access token](https://huggingface.co/settings/tokens) with **write** permission to the target repo. For better security, use a [fine-grained token](https://huggingface.co/settings/tokens) scoped to only the repository you're syncing to.

docs/hub/security-tokens.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ We also recommend using only fine-grained tokens for production usage. The impac
7272

7373
For example, if your production application needs read access to a gated model, a member of your organization can request access to the model and then create a fine-grained token with read access to that model. This token can then be used in your production application without giving it access to all your private models.
7474

75+
### For CI/CD pipelines
76+
77+
If you only need access from a CI/CD workflow (GitHub Actions, GitLab CI, CircleCI, …), you can avoid storing an access token as a CI secret entirely. See [Trusted Publishers](./trusted-publishers), which exchanges your CI provider's OpenID Connect (OIDC) identity token for a short-lived Hub token at the start of each run — either repo-scoped (to publish models, datasets, Spaces or kernels) or user-scoped (to read your gated repos and get your account's rate limits).
78+
7579
### For Enterprise organizations
7680

7781
If your organization needs to programmatically issue tokens for members without requiring each user to create their own token, see [OAuth Token Exchange](./oauth#token-exchange-for-organizations-rfc-8693). This Enterprise plan feature is ideal for building internal platforms, CI/CD pipelines, or custom integrations that need to access Hugging Face resources on behalf of organization members.

docs/hub/trusted-publishers.md

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
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

Comments
 (0)