The first publish to GitHub Packages usually fails, and the error message points in the wrong direction:

npm error code E403
npm error 403 Forbidden - PUT https://npm.pkg.github.com/@myscope%2fmy-library
npm error 403 Permission permission_denied: write_package

That looks like a credentials problem. It is nearly always a naming rule: GitHub Packages requires the package scope to match the account or organisation that owns the repository, and no token can override that.


Root Cause

GitHub Packages namespaces every package under its owner. A repository at github.com/acme/my-library can publish @acme/my-library and nothing else — not @myscope/my-library, not an unscoped my-library. The registry derives the permitted namespace from the authenticated identity and the repository, so a mismatch is rejected as a permission failure rather than a validation error, which is why the message misleads.

Two further rules follow from the same design. The repository field in the manifest is used to associate the package with a repository, so an absent or incorrect one produces its own class of failure. And every read, including of public packages, requires authentication — GitHub Packages has no anonymous access tier.


Minimal Reproduction

{
  "name": "@myscope/my-library",
  "version": "1.0.0",
  "publishConfig": { "registry": "https://npm.pkg.github.com" }
}
- run: npm publish
  env:
    NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Repository owner: acme. Package scope: @myscope. Result: 403, on every attempt, with any token.


The Ownership Rule

Scope must match owner A repository owned by acme may publish packages scoped at acme. A package scoped differently is rejected with a permission error regardless of the token used. The namespace is derived, not chosen accepted github.com/acme/my-library publishes "@acme/my-library" rejected with 403 github.com/acme/my-library publishing "@myscope/my-library" unscoped names are rejected too every package on this registry lives under an owner's namespace

Step-by-Step Fix

1. Rename the package to match the owner

   {
-    "name": "@myscope/my-library",
+    "name": "@acme/my-library",
     "version": "1.0.0"
   }

If the package already exists on the public registry under the old name, this is a rename rather than a configuration change and needs its own migration — the two names are different packages to every consumer.

2. Add the repository field and pin the destination

{
  "name": "@acme/my-library",
  "publishConfig": {
    "registry": "https://npm.pkg.github.com"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/acme/my-library.git"
  }
}

3. Grant the workflow packages write permission

name: Publish
on:
  push:
    tags: ['v*']

permissions:
  contents: read
  packages: write          # required — the default token cannot publish without it

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22.11.0'
          registry-url: 'https://npm.pkg.github.com'
          scope: '@acme'
      - run: npm ci
      - run: npm run build
      - run: npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expected result: the publish succeeds and the package appears under the repository’s Packages tab.

HAZARD PREVENTION

Symptom: The publish succeeds from CI but fails from a maintainer’s laptop with 401 Unauthorized.

Root cause: A local ~/.npmrc has a token for the public registry but none for npm.pkg.github.com, and npm does not fall back between hosts.

Fix: Add a per-host auth line locally (//npm.pkg.github.com/:_authToken=${GH_PACKAGES_TOKEN}) referencing an environment variable, and prefer publishing from CI so the laptop path is rarely exercised at all.

4. Configure consumers, including for public packages

What a consumer must configure Installing from the public npm registry requires no configuration. Installing from GitHub Packages requires a scope line, an auth line and a token, even when the package is public. Public here does not mean anonymous public npm registry npm install @acme/my-library no .npmrc, no token works on any machine, including a fresh CI runner GitHub Packages @acme:registry=npm.pkg.github.com plus an auth token, always every developer and pipeline needs it configured first
# consumer .npmrc
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GH_PACKAGES_TOKEN}

For a consuming workflow in a different repository, the built-in token is not enough — it has no rights to another repository’s packages — so a personal access token with read:packages, or an organisation-level token, has to be supplied as a secret.


Verification

# the package exists on the intended registry
npm view @acme/my-library version --registry=https://npm.pkg.github.com

# and not on the public one
npm view @acme/my-library version --registry=https://registry.npmjs.org 2>&1 | head -1

# a clean consumer install works with only the documented configuration
d=$(mktemp -d); cd "$d" && npm init -y >/dev/null
printf '@acme:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=%s\n' "$GH_PACKAGES_TOKEN" > .npmrc
npm install @acme/my-library --silent && echo "consumer install ok"

Expected: a version number, an E404 from the public registry, and consumer install ok.


Edge Cases / Gotchas

  • Deleting a package version is permanent and rate-limited. GitHub allows deletion under conditions that differ from npm’s 72-hour window; treat a mistaken publish as permanent and recover forward.
  • The Packages tab shows the repository association, not the source. A package published from a workflow in a different repository can appear under an unexpected project if the repository field disagrees.
  • Organisation package visibility is separate from repository visibility. A package can be private while its repository is public, and the defaults differ between organisation settings, which surprises people migrating from npm.
  • npm audit behaves differently. Advisory data comes from the public registry, so vulnerabilities in packages hosted only on GitHub Packages are not reported by the standard audit workflow.
  • Installing in a fork requires the fork owner’s token. A contributor who forks the repository cannot install the package with their fork’s GITHUB_TOKEN, because the namespace still belongs to the upstream owner — which makes external contribution workflows noticeably more awkward than they are on the public registry, and is worth mentioning explicitly in a contributing guide.
  • Scope renames break every consumer. Moving from @myscope to @acme is a new package name; publish a final version of the old name that is deprecated with a pointer to the new one.

One last consideration before committing to this registry: everything above is configuration that every consumer inherits. For an internal package inside one organisation that cost is paid once by a platform team and forgotten. For a package intended to be adopted outside it, the authentication requirement is a permanent adoption barrier that no amount of documentation removes, and the public registry is almost always the better home.


Frequently Asked Questions

Why does my publish fail with a 403 even though the token is valid?

The scope must match the account or organisation that owns the repository. Any other scope returns a permission error that reads like authentication but is a naming rule.

Can the built-in GITHUB_TOKEN publish packages?

Yes, for packages owned by the same repository, provided the workflow grants packages: write. It cannot publish to another repository’s namespace or install private packages from a different repository.

Do consumers need authentication for public GitHub Packages?

Yes — a token is required even for public packages, so every consumer and pipeline needs credentials. This is the biggest practical difference to weigh before choosing it for externally-consumed code.

Does GitHub Packages support provenance attestations?

No. The --provenance flag targets the public npm registry’s attestation storage, so publishing here means giving up the signed link between tarball and commit.

Can one package be published to both GitHub Packages and npm?

Yes, by running publish twice with an explicit registry override each time. Keep versions identical and publish the same tarball, or the two histories diverge under one version number.



↑ Back to Publishing to Private and Alternative Registries