Skip to content

Package publishing and registries

Put a human signature between your pipeline and the registry.

The ChainDrop worm republished itself into 433 npm packages in roughly four hours using stolen maintainer credentials — every release built by a legitimate workflow, with valid provenance. Nothing in that loop required a person. A publish gated on a fresh passkey signature over the exact repository, commit, registry, package, version, and artifact digest is the step a worm cannot complete autonomously.

The integration point

Split the process across two independently administered trust boundaries. The repository-controlled build job produces an artifact without publishing authority and requests approval for its exact release metadata. A separately managed release workflow, pinned to reviewed code, retrieves the artifact, recomputes its digest, verifies the human approval locally, and only then obtains short-lived authority to publish.

  1. The repository-controlled build job produces the package without access to publishing authority.
  2. It computes the artifact digest and requests approval for the exact repository, commit, registry, package name, version, and digest.
  3. A maintainer approves — or does not — using an enrolled passkey or hardware key.
  4. The artifact and approval reference are transferred, immutably, to a separately administered release workflow.

The INTYGA key in your CI secrets is a requester credential. Harvesting it gives an attacker the ability to request approval, not the authority to grant approval or enter the protected publish path.

# Repository-controlled workflow — .github/workflows/release.yml
jobs:
  build:                       # untrusted — no publishing authority
    runs-on: ubuntu-latest
    outputs:
      approval-id: ${{ steps.approval.outputs.approval-id }}
    steps:
      - uses: actions/checkout@<FULL_COMMIT_SHA>
      - run: npm ci
      - run: npm pack --pack-destination dist
      - uses: actions/upload-artifact@<FULL_COMMIT_SHA>
        with:
          name: release-artifact
          path: dist/*.tgz
          if-no-files-found: error
      - id: approval
        run: node request-publish-approval.mjs dist/*.tgz
        env:
          INTYGA_API_KEY: ${{ secrets.INTYGA_REQUESTER_KEY }}

  release:
    needs: build
    # Separately administered, pinned to immutable reviewed code.
    uses: acme/security-workflows/.github/workflows/npm-release.yml@<FULL_COMMIT_SHA>
    with:
      artifact-name: release-artifact
      approval-id: ${{ needs.build.outputs.approval-id }}
    secrets: inherit

The protected release workflow

The release workflow lives where the package repository cannot modify it: a separate security or platform repository, or a centrally managed reusable workflow — referenced by an immutable commit SHA, never a mutable branch or tag. Its trusted maintainer identities come from protected configuration, and it prefers short-lived or federated publishing authority over a long-lived registry token.

  1. The protected workflow recomputes the artifact digest from the bytes it received.
  2. It reconstructs the expected approval payload from protected configuration — a receipt is never allowed to declare its own trusted signer.
  3. It verifies the receipt locally against trusted maintainer identities.
  4. Only after successful verification does it obtain short-lived publishing authority.
  5. It publishes exactly the verified artifact, once.
# acme/security-workflows — npm-release.yml (not editable from the package repo)
jobs:
  release:
    runs-on: ubuntu-latest
    environment: npm-release
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/download-artifact@<FULL_COMMIT_SHA>
        with:
          name: ${{ inputs.artifact-name }}
          path: dist
      - run: node recompute-and-verify-release.mjs dist/*.tgz
        env:
          INTYGA_APPROVAL_ID: ${{ inputs.approval-id }}
          INTYGA_WEBAUTHN_ORIGIN: ${{ vars.INTYGA_WEBAUTHN_ORIGIN }}
          INTYGA_WEBAUTHN_RP_ID: ${{ vars.INTYGA_WEBAUTHN_RP_ID }}
      - run: npm publish dist/*.tgz --provenance

// recompute-and-verify-release.mjs
const action = {
  target: "npmjs:@acme/api-client",
  actionType: "package_publish",
  params: {
    repository: "https://github.com/acme/api-client",
    commit: "a18ef92d4f...",
    registry: "https://registry.npmjs.org",
    name: "@acme/api-client", version: "4.2.0",
    artifactDigest: { algorithm: "sha512", value: TARBALL_SHA512 },
  },
};

// never trust the build job's digest — hash the bytes actually received
const actualDigest = await sha512File(artifactPath);
if (actualDigest !== action.params.artifactDigest.value)
  throw new Error("artifact digest does not match the approved release");

// nonce and approvers come from protected configuration —
// a receipt cannot vouch for its own signer.
const check = verifyApprovalReceipt(receipt, {
  ...action, nonce, approvers: TRUSTED_MAINTAINERS,
}, {
  expectedOrigin: process.env.INTYGA_WEBAUTHN_ORIGIN!,
  expectedRpId: process.env.INTYGA_WEBAUTHN_RP_ID!,
});
if (!check.ok) throw new Error("receipt mismatch: " + check.reason);
// npm publish runs as the next step — authority exists only here

Where the security boundary actually is

The security boundary is not the API call to INTYGA. The security boundary is the separately administered release environment that refuses to obtain publishing authority unless the exact approval verifies.

Why credential-only publishing is wormable

Credentials are harvestable

ChainDrop collected npm tokens, GitHub credentials, and cloud keys from every CI environment it infected. Anything a machine can read, a worm running on that machine can read.

Provenance is not intent

Origin and intent are different claims. Provenance proves how an artifact was built — and ChainDrop's releases carried valid provenance, because the legitimate workflows built them. INTYGA proves that a human approved the exact artifact for release.

Propagation needs no human

433 of the 444 compromised packages were republished automatically with harvested credentials. A signature ceremony per publish is the step that cannot run unattended.

Incident figures from StepSecurity's ChainDrop analysis (August 2026). For the full incident walkthrough and the reasoning behind this architecture, read the engineering-blog analysis of ChainDrop and wormable publishing.

What this breaks, and what it does not

Broken: the propagation loop

A worm with complete access to every credential available inside the build environment can request approval. It cannot autonomously enter the separately protected publish path without obtaining a valid approval from an enrolled maintainer authenticator. Each unattended republish — the mechanism that turned 11 compromised packages into 444 — stops at an approval request instead of immediately becoming a release: the request remains incomplete, is denied, or expires unless an enrolled maintainer explicitly approves it, and the resulting outcome is recorded.

Not broken: a fooled maintainer

If a poisoned commit reaches your release branch and you approve the release, you have signed the poison. The gate proves a person decided; it does not review the diff. Keep code review on release branches and a minimum-release-age policy on your dependencies — this control composes with those, it does not replace them.

The gate itself must be protected

A release gate is only independent if the package repository cannot modify or bypass it as part of the same compromised change. A secure deployment should therefore ensure that:

  • the protected release workflow is administered separately,
  • workflow references are pinned to immutable reviewed revisions,
  • trusted maintainer identities come from protected configuration,
  • publishing authority is unavailable to the build job,
  • the protected workflow recomputes the artifact digest,
  • and only the approved artifact can be published.

A workflow that can both change the package and remove its own approval requirement does not create an independent authorization boundary.

This pattern extends beyond package publishing

The same architecture applies anywhere a machine performs an irreversible action on behalf of a human: cargo publish, docker push, helm releases, Terraform applies and production deployments, and privileged infrastructure changes. Package publishing is only one example. The security property is broader: possession of a credential is no longer sufficient authority. A human decision becomes part of the execution path.

Show the gate on your README

A gated release process is worth advertising: it tells your users that a stolen credential cannot ship your next version. Once your publish workflow requires an approval, embed a badge.

Release gated by INTYGAHuman-approved releases with INTYGAProtected by INTYGA
[![Release gated by INTYGA](https://intyga.com/badges/release-gated-by-intyga.svg)](https://intyga.com/use-cases/package-publishing)

Gate every release on the free tier.

Releases are not metered: the Developer tier gates as many publishes as you ship, at no cost, and an approval is never refused for billing reasons. What the paid tiers add is seats, a longer evidence-retention window, and quorum rules.

See pricing →