Skip to content

Trusted Publishing

Thomas Neidhart edited this page Nov 5, 2026 · 5 revisions

Trusted Publishing

Trusted publishing lets a CI workflow publish an extension without storing an Open VSX access token as a secret. Instead of a long-lived token, the workflow proves its identity to the registry with an OpenID Connect (OIDC) ID token issued by the CI system itself, and the registry exchanges that proof for a short-lived token that may publish exactly one extension.

The model follows the OpenSSF Securing Software Repositories WG guidance, Trusted Publishers for All Package Repositories, which is also what PyPI, npm, RubyGems, NuGet, crates.io and pub.dev implement.

Contents


Why use it

A personal access token stored in CI is long-lived, may publish anything its owner may publish, and has to be rotated by hand. It is only as safe as every place it has been copied to.

A trusted publisher replaces it with a registration that says this specific workflow, in this specific repository, may publish this specific extension. There is no secret to leak, nothing to rotate, and a token the registry issues under it expires within minutes and is scoped to a single extension.

Before you start

To register a trusted publisher you need:

  • Ownership of the namespace. Being a contributor is not enough — see Namespace Access for how to claim ownership.
  • A signed Publisher Agreement, the same one that ordinary publishing requires (see Publishing Extensions).
  • The extension to exist, with at least one active version. A trusted publisher is registered for an extension, so the first version has to be published the ordinary way, with an access token. Registrations for extensions that do not exist yet are rejected.

Each extension can have one trusted publisher at a time. A workflow that publishes several extensions needs a registration per extension, all of which may point at the same workflow.

The extension has to stay active. The requirement is not only about registering: every token exchange looks the extension up among the active ones. Delete every version of an extension and its registration stops working — the exchange answers "No trusted publisher matches the presented token" even though the registration is still listed on the settings page. It starts working again as soon as a version is active again. Deleting only some versions changes nothing.

Register a trusted publisher

  1. Go to Settings → Trusted Publishers (/user-settings/trusted-publishers) and pick the namespace.
  2. Choose the provider. Which providers appear depends on what the registry has enabled; the dialog lists them.
  3. Fill in the fields that identify the workflow and confirm.

GitHub Actions

Field Meaning Example
Organization or User name Owner of the repository octo-org
Repository name Repository the workflow lives in octo-repo
Workflow filename Filename only, must exist in .github/workflows/ publish.yml
Environment name Optional. When set, only runs in that environment are trusted publish

GitLab CI/CD

Field Meaning Example
Namespace Group or user the project belongs to my-group
Project name Project the pipeline runs in my-project
Top-level CI filename Filename of the pipeline definition .gitlab-ci.yml
Environment name Optional. When set, only jobs in that environment are trusted production

On confirmation the registry resolves the names against the provider's API and stores the resulting numeric IDs alongside them. See Resurrection and rename for why. If the registry cannot look the repository up, as is the case for a private one, the registration stays pending until its first publish; see Private repositories.

A registration is not tied to a branch or tag: any ref that runs the registered workflow file is trusted. Pin an environment if you need publishing restricted further.

Private repositories

Available from registry version 1.4.0. Earlier versions reject the registration of a repository they cannot look up. The ovsx CLI needs a release that supports --registrationCode.

The registry looks a repository up through the provider's public API, without credentials. A private repository cannot be found that way, so its numeric IDs are unknown at registration time. Instead of asking you to enter them, the registry registers it as pending and completes the registration on the first publish. You do not fill in anything extra: the dialog is the same.

  1. Register the trusted publisher as usual. For a repository that cannot be looked up, the dialog shows a registration code instead of closing.

  2. Copy the code now. It is shown only once, the registry keeps only a hash of it, and it cannot be retrieved later. Closing the dialog needs the Done button, so a stray click does not lose it.

  3. Store it where the workflow can read it, for example as a secret of the repository, and pass it to the first publish (see CLI options):

    - run: npx ovsx publish --trusted-publishing
      env:
        OVSX_REGISTRATION_CODE: ${{ secrets.OVSX_REGISTRATION_CODE }}
  4. On that publish the registry verifies the ID token, checks that its repository (or project) names equal the registered ones and that the code is correct, and stores the repository IDs from the token. The registration is then complete and behaves like any other. Later publishes do not need the code, and it cannot be used again.

Until then, the Trusted Publishers page lists the registration as Pending together with the time its code is valid until.

  • The code is valid for 7 days. After that the registration can no longer be completed. The registry removes expired pending registrations once a day, and registering the same extension again replaces an expired one. The page marks a registration whose code has expired.
  • The code is single-use and secret. Whoever holds it, and can run the registered workflow, can complete the registration. Prefer the environment variable to the command line option: command line arguments are visible to other processes on the machine and in shell history. Remove the secret once the registration is complete.
  • A repository that does not exist is also pending. The registry cannot tell a private repository from a mistyped name. If the first publish is rejected, check the owner, repository and workflow names you registered; they are compared exactly, including case.
  • Complete it promptly. While a registration is pending, only the names are known, and they are protected by the code alone. See Resurrection and rename.

Publish from CI

GitHub Actions

The job needs the id-token: write permission; the ovsx CLI detects the rest.

permissions:
  contents: read
  id-token: write
jobs:
  publish:
    runs-on: ubuntu-latest
    # only needed if the trusted publisher pins an environment
    environment: publish
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v5
      - run: npm ci
      - run: npx ovsx publish --trusted-publishing

GitLab CI/CD

GitLab hands the ID token to the job as a variable, which ovsx reads from OVSX_ID_TOKEN:

publish:
  id_tokens:
    OVSX_ID_TOKEN:
      aud: https://open-vsx.org
  script:
    - npx ovsx publish --trusted-publishing

CLI options

Option Environment variable Meaning
--trusted-publishing Require trusted publishing; fail if no ID token can be obtained. Without it, trusted publishing is used whenever an ID token is available and no access token was given
--idToken <token> OVSX_ID_TOKEN Pass the ID token explicitly, for CI systems that expose it as a variable
--oidcAudience <aud> OVSX_OIDC_AUDIENCE Audience to request for the ID token; defaults to the registry URL
--registrationCode <code> OVSX_REGISTRATION_CODE Registration code of a pending trusted publisher, for a private repository. Needed on the first publish only; requires registry 1.4.0 or later

The registration code is sent only when trusted publishing is used. A --pat (or OVSX_PAT) always takes precedence, so an existing workflow keeps publishing with its access token until you remove it.

How the exchange works

  1. The workflow asks its CI system for an OIDC ID token, for an audience that is the registry's URL.
  2. ovsx posts it to POST /api/-/trusted-publishing/token, together with the namespace and extension name it intends to publish.
  3. The registry parses the token without trusting it to read the iss claim, and selects the provider implementation registered for that issuer.
  4. That provider fetches the issuer's OpenID Connect discovery document and JSON Web Key Set, and verifies the signature and standard claims. The aud claim must equal the audience the registry is configured for, and tokens carrying x5u, x5c, jku or jwk headers are rejected outright.
  5. The claims of interest are extracted and matched against the stored registration for that extension, as described below.
  6. On a match the registry issues a short-lived access token and returns it. ovsx uploads with it, and never writes it to the token store.

A token that verifies but matches nothing answers with a generic "No trusted publisher matches the presented token" — the same message whether the namespace, the extension or the registration was the mismatch, so the endpoint cannot be used to probe which extensions have a registration.

What the registry matches

The registration stores immutable IDs as well as the names you typed, and matching is done on the IDs:

Provider Claims that must match
GitHub Actions repository_id, repository_owner_id, workflow_ref (ignoring the trailing @<ref>), and environment when the registration pins one
GitLab CI/CD project_id, namespace_id, ci_config_ref_uri (ignoring the trailing @<ref>), and environment when the registration pins one

Resurrection and rename

Owner, repository and project names are mutable: they can be renamed or deleted and re-registered by somebody else. Numeric IDs cannot. Because the registry resolves and stores the IDs when you create the registration, a repository that is deleted and re-created under the same name by another party gets a new ID and no longer matches — the registration simply stops working instead of trusting the impostor.

The workflow filename has no ID and is matched by name, which is why the file itself must stay under your control.

A pending registration has no IDs yet, so until its first publish it is matched on the names and the registration code, and takes the IDs from the first token that passes both. That is why the code matters and why it should be used soon after registering.

The issued token

The token the exchange returns is an ordinary registry access token with three restrictions:

  • it expires within minutes (five by default),
  • it is scoped to the one extension the registration was made for, and
  • it acts as the user who created the registration, so publishing shows that user as the publisher.

Versions published this way are marked on the extension page: a rocket icon next to Published by means the version came from a trusted publishing workflow rather than a personal access token.

Security model

Trusted publishing removes the long-lived secret, not every risk. Two things stay your responsibility, as the OpenSSF guidance puts it:

  • The tokens are still sensitive. Both the OIDC ID token and the token the registry issues can publish until they expire. Do not print them, and treat CI logs accordingly.
  • A registration is trust in external state. Whoever can change the registered workflow file, or can run it, can publish the extension. Protect the branch, review changes to the workflow, and prefer a pinned environment with required reviewers when the extension warrants it.

Known limitations

  • The publisher field in package.json must match the namespace's case exactly. Namespace lookups elsewhere in the registry are case-insensitive, but the final check that authorizes a trusted-publishing token against the extension being published compares the namespace name case-sensitively. If your namespace is SeniorTurkmen, publishing with "publisher": "seniorturkmen" (or any other casing) in package.json is rejected. Until this is fixed, make the publisher field match the namespace's registered case exactly. See issue #2279.

Changing or removing a registration

A registration cannot be edited — delete it and create a new one. Both are on the same Trusted Publishers settings page. Deleting takes effect on the next exchange, which then fails and stops CI publishing until a new registration exists; a token issued moments earlier stays valid until it expires.

Delete a registration when the repository moves, when the workflow file is renamed, or when the extension should no longer be published from CI.

The registry deletes registrations by itself in three cases, since a registration must not outlive what it depends on:

  • Its author stops being an owner of the namespace — by being demoted to contributor, by being removed from the namespace, or by having their contributions revoked by an administrator. Only that person's registrations go; the other owners' remain. Publishing tokens already issued under a deleted registration stop working immediately.
  • Its registration code expires. A pending registration that was not completed within 7 days is removed by a daily job, as it can no longer be completed.
  • The extension is purged. Deleting an extension's versions leaves the registration in place (inactive, as described above); purging the extension outright removes it.

For registry operators

Trusted publishing is off by default. The relevant server properties:

Property Default Meaning
ovsx.trusted-publishing.enabled false Enables the feature
ovsx.trusted-publishing.audience ${ovsx.webui.url} The aud claim that ID tokens must carry
ovsx.trusted-publishing.token-expiration PT5M Lifetime of the issued publishing token. Must be positive — issued tokens always expire
ovsx.trusted-publishing.active-providers github Comma-separated provider ids that may be used
ovsx.trusted-publishing.forbidden-jwt-headers x5u,x5c,jku,jwk JWT headers that cause a token to be rejected
ovsx.trusted-publishing.gitlab.<id>.name / .url / .issuer gitlab → gitlab.com Defines a GitLab instance as a provider; issuer defaults to url

GitHub is a single, built-in provider. GitLab instances are configuration, so any instance — the public one, the Eclipse Foundation's, a self-hosted one — is added by declaring it and listing its id under active-providers:

ovsx:
  trusted-publishing:
    enabled: true
    active-providers: github,eclipse-gitlab
    gitlab:
      eclipse-gitlab:
        name: Eclipse GitLab
        url: https://gitlab.eclipse.org

The provider id is stored with every registration, so renaming an id hides the registrations made under it.

Further reading

Clone this wiki locally