Death by a thousand tokens: designing least-privilege registry credentials for CI

Omni Line ·

9 min read Original article ↗

Somewhere in your CI, right now, there is a registry token that can publish packages — attached to a job that only ever installs them. Nobody designed it that way. A deploy was failing at 6pm, someone pasted the token that worked, and the pipeline has been green ever since. Multiply that by every repository, every ecosystem, and every year the team has existed, and you get the standard enterprise credential posture: dozens of tokens, mostly over-scoped, mostly unowned, none of them safe to revoke.

This post is a procedure for getting out of that state: what registry tokens in the major ecosystems can actually do, a layout that gives each pipeline exactly the access it needs, and the failure modes you will hit while rolling it out.

The problem: tokens accrete, they aren't designed

Registry credentials have three properties that make them accumulate badly:

  1. They work from anywhere. Unlike a cloud IAM role, a registry token is usually a bearer secret. Whoever holds the string has the access, from any network, until it expires or is revoked.
  2. They fail loudly and asymmetrically. An over-scoped token never causes an incident until it leaks. An under-scoped token breaks a deploy today. Every incentive during an outage points toward granting more access, never less.
  3. Nobody owns them. A token created by an engineer who left two years ago still authenticates. Revoking it is a game of production roulette, so nobody does.

The result is an inventory question you probably can't answer: which credentials can publish to your registries, and which pipelines actually exercise that permission? If an auditor — or an attacker doing post-exploitation triage on a leaked CI log — can answer it faster than you can, that's the gap this procedure closes.

What a registry token can actually do

Scoping only works if you know what the underlying credential model supports. The three ecosystems most teams run first differ more than you'd expect.

npm

npm's classic tokens come in three types: read-only, automation, and publish. The trap is that automation tokens were designed for CI publishing — they bypass two-factor authentication on publish. A leaked automation token is a publish credential with no second factor, which is exactly what the 2FA requirement on your account was supposed to prevent. Granular access tokens improve this: they can be limited to specific packages or scopes, given an expiry, and restricted to read-only. If your CI still uses a classic token created before granular tokens existed, assume it is over-scoped.

PyPI

PyPI API tokens are either project-scoped or account-scoped. There is no read-only API token, because installing from PyPI requires no authentication at all — every PyPI token is a publish credential. That inverts the usual mental model: on PyPI, possessing a token is the dangerous state, and the right number of long-lived tokens is close to zero. Trusted publishing (OIDC between your CI provider and PyPI) replaces the stored secret with a short-lived token minted per workflow run, which is strictly better when your CI platform supports it.

Docker

Docker Hub personal access tokens support read-only, read-write, and read-write-delete scopes — but they are account-wide, not per-repository. And in practice, many pipelines don't use PATs at all: they use a bot account's username and password in a docker login step, which grants everything the account can do. A registry credential that is literally a password should be the first thing your cleanup removes.

The common thread: upstream public registries give you coarse scoping levers, and each ecosystem's levers are different. Any layout you design has to work within — or route around — those limits.

Constraints before design

Write these down for your team before touching anything; they determine how aggressive the layout can be:

  • How many ecosystems? A JVM+npm+Docker shop has three credential models to reconcile; a polyglot platform team might have eight.
  • Where do secrets live? CI-native secrets scoped per repository and per environment are workable. A shared "org secrets" bucket that every workflow can read is not a secret store; it's a distribution mechanism for leaks.
  • What's the blast radius of a bad rotation? If one token is embedded in 40 repositories, rotating it is a change-management event. That fan-out is itself a finding.
  • Compliance clock. If you're subject to SOC 2 or ISO 27001, credential inventory and rotation evidence are audit items. Design the layout so the evidence falls out of it for free.

The layout: one consumer, one token, one scope

The rule that does most of the work: a token belongs to a consumer, not to a person, and each consumer gets exactly one. A consumer is a pipeline × registry pair: "the CI install step for the payments repo against the npm registry" is one consumer. "Whatever Dave's token can do" is not.

From there, the layout is a matrix you can keep in a README:

Consumer Registry Access Expiry Owner
payments CI install npm (internal) read 90d payments team
payments release job npm (internal) publish: @acme/payments-* 90d payments team
base-image build Docker (internal) read upstream, write acme/base-* 90d platform team
security scanner all read 30d security team

Four rules make the matrix real:

  1. Install and publish never share a credential. The install step runs on every push, on every branch, including pull requests from forks if you're careless. The publish step runs on a tagged release from a protected branch. Their exposure profiles are wildly different, so their credentials must be too.
  2. Name tokens for their consumer. payments-ci-install-npm tells you what breaks when you revoke it. token-2 tells you nothing, which is why it will still exist in five years.
  3. Store the token where only its consumer can read it. Repository-scoped CI secrets for install tokens; environment-scoped secrets with deployment protection rules for publish tokens. In GitHub Actions terms:
# .github/workflows/release.yml
jobs:
  publish:
    runs-on: ubuntu-latest
    environment: release   # publish token lives here, behind branch protection
    steps:
      - run: |
          npm config set //registry.example.com/:_authToken "${NODE_AUTH_TOKEN}"
          npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_PUBLISH_TOKEN }}
  1. Expiry is not optional. A token without an expiry is a token whose rotation will never be scheduled. Pick 90 days for publish credentials, and rotate with overlap: issue the new token, roll consumers over, verify, then revoke the old one. Rotation that starts with revocation is how Friday deploys die.

Humans are deliberately absent from this matrix. People authenticate to registries interactively — ideally through your identity provider — and their personal credentials never get pasted into CI. The moment a personal token enters a pipeline, its lifecycle is coupled to that person's employment, and offboarding becomes an outage.

Failure modes to plan for

The token in the build log. npm config list, a debug-mode curl -v, or a stack trace can echo credentials into logs that entire orgs can read. CI secret-masking catches exact string matches and misses encodings. Treat any token that ever appeared in a log as leaked; this is where short expiry quietly saves you, because the exposure window is bounded even when nobody notices.

The rotation that broke everything. You revoked first and rolled out second, or the token was copied into repositories your inventory didn't know about. The fix is structural: if the matrix says one consumer per token, a rotation can only ever break one pipeline.

The break-glass token that became the default. Every team needs an emergency admin credential. Keep it in a sealed secret manager entry with an alert on read access — not in a CI variable — and audit its use. If break-glass shows up in normal deploy telemetry, your standard tokens are under-scoped and the emergency path has become load-bearing.

The orphan. Offboarding checklists reliably revoke SSO and forget registry tokens, because registry tokens aren't tied to the identity provider. Any credential whose lifecycle isn't coupled to either an identity system or an expiry date will outlive its owner.

How this looks on a consolidated registry

Everything above gets harder in proportion to how many credential systems you operate. Three ecosystems from three vendors means three token formats, three scoping models, three rotation UIs, and three places your inventory can drift.

This is the operational case for putting a single self-hosted registry in front of your ecosystems. On Omni Line, the layout above collapses into one model: automation authenticates with personal access tokens (omni_…) whose scopes are intersected with the organization's RBAC on every request — a write-scoped token held by an account with a reader role cannot publish, so an over-scoped token pasted into the wrong pipeline fails closed instead of escalating. Humans authenticate through sessions or OIDC SSO and never mint long-lived CI credentials at all; the human/automation split from the matrix is enforced by the auth model rather than by team discipline. And because npm, PyPI, Docker, Maven, and the rest sit behind one API, the token inventory is one list in one place — which is the artifact your auditor asked for in the first place. The specifics are in the personal access token docs.

Takeaways

  • Inventory first: list every registry credential, its scope, its consumers, and its owner. The unowned ones are your risk register.
  • Learn each ecosystem's real scoping levers — npm automation tokens bypass 2FA, every PyPI token can publish, Docker bot passwords are unscoped — and stop assuming a "token" is read-only anywhere.
  • One consumer, one token, one scope, one expiry. Install and publish never share.
  • Rotate with overlap, never revoke-first. Keep break-glass sealed and alarmed.
  • Fewer credential systems means a smaller matrix: consolidating registries shrinks the problem before you automate it.

None of this requires new tooling to start. The matrix is a README, the first rotation is an afternoon, and the payoff is being able to answer "who can publish to our registry?" faster than an attacker can.