npm 公開を Trusted Publishing で多層防御する — トークンレス OIDC・ステージ公開・PR マージ起点リリースの設計

重岡 正 · Thu, June 25, 2026

公開 npm レジストリへパッケージを出すとき、いまだに NPM_TOKEN を GitHub の Secrets に置いて npm publish を回している現場は少なくありません。2024 年まではそれが普通でした。しかし 2025 年から 2026 年にかけて npm エコシステムを襲った自己増殖型のサプライチェーン攻撃(2025 年 9 月の Shai-Hulud ワームと、それに続く 2026 年の亜種)を経て、長期有効な publish トークンは「漏れたら終わり」の単一障害点として扱われるようになりました。npm 自身も classic トークンを revoke・非推奨化し、CI を短命な per-run 認証情報へ寄せています。

本記事では、その新しい既定値を、実際に動くレシピとして組み立てます。題材は配布チャネルごとのレシピを集めた公開リポジトリ cli-distribution-recipesnpmjs-public レシピで、サンプル CLI @codenote-net/hello-cli を公開 npmjs.com へ公開します。ゴールはこうです。

npx @codenote-net/hello-cli
Ohayou gozaimasu, Konnichiwa, Konbanwa!

そして npmjs.com のパッケージページに「Built and signed on GitHub Actions」の provenance(来歴)が、このリポジトリと公開ワークフローへのリンク付きで表示される。これを、NPM_TOKEN を一切置かずに達成します。

この設計の下敷きには、azu さんの「Hardening npm publishing (2026)」があります。本記事はそのモデルを、単一パッケージのリポジトリで端から端まで動かしながら整理したものです。

なぜ Trusted Publishing なのか

Trusted Publishing は、GitHub Actions の OIDC を使って、npm への公開を認証する仕組みです。2025 年 7 月に GA となりました。発想を一言で言えば、「保管された秘密」を「短命な身元証明」に置き換えることです。

従来の npm publish は、長期有効なトークンを CI に渡して認証していました。このトークンは、Secrets に置かれている限りずっと有効で、ログに漏れれば誰でもどこからでも公開できてしまいます。実際、ワークフローを改ざんして OIDC トークンや認証情報をログ経由で抜き出す攻撃(Bitwarden CLI のケースが知られています)は、この「持ち出せる秘密」を狙ったものでした。

Trusted Publishing では、公開のたびに GitHub Actions が短命の OIDC トークンを発行し、npm 側は事前に登録された Trusted Publisher の条件(GitHub org・リポジトリ・ワークフローファイル名・Environment 名)と照合してから公開を許可します。保管される秘密はどこにもありません。さらに公開リポジトリ・公開パッケージであれば、npm が provenance 証明を自動で添付し、「このパッケージはこのリポジトリのこのコミットのこのワークフローから出た」という来歴が検証可能になります。

つまり Trusted Publishing は、ほかのレジストリ向けレシピ(AWS CodeArtifact、Azure Artifacts、Google Cloud Artifact Registry)すべての土台になる、もっとも基本的な公開チャネルです。ここで secure-by-default を作り込んでおくと、その投資がリポジトリ全体に効きます。

設計思想 — 多層防御で「権限ひとつ」を無効化する

このレシピの設計を貫く原則は、多層防御(defense in depth)です。狙いは、悪意ある公開を成立させるために、攻撃者が独立した複数のシステムを同時に侵害しなければならない状態を作ることにあります。

具体的には、live な npm パッケージが世に出るまでに、次のすべてを通過させます。

  • このリポジトリへの write 権限
  • Trusted Publisher の条件に一致する OIDC 交換(ワークフローファイルと Environment の一致)
  • 保護された GitHub Deployment Environment での人間の承認
  • ステージされたパッケージを live へ昇格させる npm の MFA 承認

ここで核心になるのが、最後のふたつを CI の外へ追い出す設計です。後述するとおり、このレシピの CI はパッケージを「ステージ」までしかできません。live への昇格は、npm 側で MFA を要する別操作として、人間が手で行います。これにより、ワークフローを書き換えてリポジトリ write 権限を握った攻撃者でも、それだけでは live なパッケージを公開できなくなります。実在する攻撃クラス(ワークフロー改ざん+OIDC 持ち出し、依存・公開タイミングを突く TanStack のキャッシュ汚染のようなケース)に対して、「リポジトリ書き込み」から「live リリース」への直結を断ち切るのが、この設計の目的です。

全体像

リリースの流れ全体を、先に図で示します。

flowchart TD
  A["メンテナが create-release-pr を実行"] --> B["ワークフローがバージョンを bump"]
  B --> C["Type: Release ラベル付き PR を同一リポジトリ branch から作成"]
  C --> D["人間のレビュー"]
  D --> E["PR を main へマージ"]
  E --> F["publish-hello-cli が pull_request.closed で起動"]
  F --> G{"マージ済みかつ Type: Release ラベルあり?"}
  G -- "いいえ" --> H["ジョブはスキップ"]
  G -- "はい" --> I["release Environment を要求"]
  I --> J["メンテナが Environment デプロイを承認"]
  J --> K["GitHub OIDC トークン発行"]
  K --> L["npm Trusted Publishing がリポジトリ・ワークフロー・Environment を検証"]
  L --> M["npm ci"]
  M --> N{"その version は npm に存在する?"}
  N -- "はい" --> O["ステージ前に fail"]
  N -- "いいえ" --> P["CLI 出力を検証"]
  P --> Q["npm stage publish"]
  Q --> R["パッケージは staged(live ではない)"]
  R --> S["メンテナが staged パッケージを検査"]
  S --> T{"npm MFA で承認?"}
  T -- "却下" --> U["npm stage reject"]
  T -- "承認" --> V["npm stage approve"]
  V --> W["パッケージが npm で live になる"]
  W --> X["npm view・npx・provenance を検証"]

左から右へ、独立した関門がいくつも並んでいるのが見て取れます。以下、要所を順に作り込んでいきます。

npm 側の設定 — Trusted Publisher と stage-only

最初に npm 側を一度だけ設定します。npmjs.com の @codenote-net/hello-cli のパッケージ設定で、Trusted Publishing を次のように登録します。

Provider: GitHub Actions
Organization or user: codenote-net
Repository: cli-distribution-recipes
Workflow filename: publish-hello-cli.yml
Environment name: release
Allowed actions: npm stage publish

ここで意図的に効かせているのが、Allowed actionsnpm stage publish だけを許可し、npm publish を与えない点です。この Trusted Publisher は、ステージ公開しかできません。たとえワークフローを書き換えて OIDC を握っても、それだけでは live なパッケージを push できず、別途 npm 側のステージ承認が必須になります。多層防御の最後の関門を、npm の設定そのものに埋め込んでいるわけです。

なお 2026 年 5 月 20 日以降に作成する Trusted Publisher は、許可するアクションを最低ひとつ明示的に選ぶ必要があります。npm stage publish を選んでおきます。

続いて Settings → Publishing access で、次を選びます。

Require two-factor authentication and disallow tokens

これで、長期 publish トークンによる公開そのものが禁止され、公開権限は CI の OIDC 経由にだけ流れます。リポジトリにも org にも NPM_TOKEN Secret は置きません。初回公開のためにどうしても一時トークンが要る場合でも、使ったら必ず削除し、standing token をゼロに保ちます。

GitHub 側の設定 — 保護された Environment と PR マージ ref

次に GitHub 側で、release という名前の Deployment Environment を作り、保護ルールを次のように設定します。

  • Required reviewers: メンテナを最低 1 人。デプロイ前に人間の承認を必須にします。
  • Allow administrators to bypass configured protection rules: 無効。
  • Deployment branches and tags: refs/pull/*/merge のみを許可。
  • Environment 名は、ワークフローと npm の Trusted Publisher 登録の両方と完全一致させます。

この refs/pull/*/merge への制限が、PR マージ起点リリースの要です。GitHub は pull_request イベントに対して Environment の branch 保護ルールを、実行中の PR のマージ ref(refs/pull/<番号>/merge)に対して評価します。したがって refs/pull/*/merge に絞ると、レビューを経た PR マージの経路だけが release Environment に到達でき、直接 push・フィーチャーブランチ・手動 dispatch からは到達できません。ワークフロー自体を書き換えても、レビューとマージを通さない限り公開フローが起動しない、という性質がここで生まれます。

あわせて、後段のリリース PR 自動生成のために、Settings → Actions → General の Workflow permissions で「Allow GitHub Actions to create and approve pull requests」を有効にし、Type: Release ラベルを作成しておきます。

gh label create "Type: Release" --color "0e8a16" --description "Release PR"

公開ワークフローの中身

公開ワークフロー .github/workflows/publish-hello-cli.yml は、PR が main へマージされた瞬間(pull_request.closed)に起動し、ジョブ側で「マージ済みかつ Type: Release ラベルあり」だけに絞り込みます。

on:
  pull_request:
    types:
      - closed
    branches:
      - main
 
permissions:
  id-token: write
  contents: read
 
jobs:
  publish:
    if: "${{ github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'Type: Release') }}"
    runs-on: ubuntu-latest
    environment: release
    defaults:
      run:
        working-directory: packages/hello-cli

権限は最小です。OIDC のための id-token: write と、チェックアウトのための contents: read だけを与えます。environment: release を指定することで、前段で設定した保護 Environment の承認ゲートを通過してからジョブが走ります。

ステップ側でも、いくつか地味な堅牢化を施しています。

    steps:
      - name: Checkout repository
        uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
 
      - name: Setup Node.js
        uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
        with:
          node-version: "24"
          registry-url: "https://registry.npmjs.org"
          package-manager-cache: false
 
      - name: Install required npm CLI
        run: npm install -g npm@11.15.0

サードパーティの Action はすべてタグではなくコミット SHA でピン留めします。タグは可変なので、上流が書き換えられると意図しないコードを実行してしまうためです。package-manager-cache: false で依存キャッシュを無効化しているのも、リリースジョブにキャッシュ経由の汚染経路を持ち込まないためです。Trusted Publishing は self-hosted runner では使えないので、GitHub ホストの ubuntu-latest で走らせます。Node.js 22.14 以上・npm 11.15.0 以上(ステージ公開の要件)を満たすため、ジョブ内で npm を明示的に入れ直しています。

公開の直前には、二重公開ガードと CLI 出力の検証を挟みます。

      - name: Install dependencies
        run: npm ci
 
      - name: Guard against republishing
        run: |
          PACKAGE_VERSION=$(node -p 'require("./package.json").version')
          if npm view "@codenote-net/hello-cli@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
            echo "@codenote-net/hello-cli@${PACKAGE_VERSION} is already published"
            exit 1
          fi
 
      - name: Verify CLI output
        run: |
          OUTPUT=$(node bin/codenote-hello.js)
          test "$OUTPUT" = "Ohayou gozaimasu, Konnichiwa, Konbanwa!"
 
      - name: Stage package
        run: npm stage publish

最後のステップが npm publish ではなく npm stage publish である点が、このワークフローのいちばんの肝です。CI はパッケージをステージするだけで、live にはしません。

なぜ stage-only に倒すのか

ステージ公開(staged publishing)は、いきなり live にせず、いったん「staged」状態へ公開する仕組みです。staged の間はインストールできず、メンテナが内容を検査したうえで、MFA を伴う承認で live へ昇格させます。

このレシピが stage-only に倒しているのは、リリースの権限を CI と人間で分割するためです。CI(=リポジトリの write 権限と OIDC)が握れるのは「ステージまで」で、「live 昇格」は npm 側の MFA を要する別操作として、人間の手元に残します。こうしておくと、CI が完全に乗っ取られたとしても、攻撃者にできるのはステージ止まりで、live なパッケージは作れません。

ステージされたパッケージは、認証済みのメンテナ端末から検査・承認します。

npm stage list @codenote-net/hello-cli
npm stage view <id>
npm stage download <id>
 
# 問題なければハードウェア MFA で承認
npm stage approve <id>
 
# おかしければ却下
npm stage reject <id>

npmjs.com の Staged Packages タブから検査・承認することもできます。なおステージ承認はパッケージ単位の操作で、大きな monorepo ではうまくバッチ化できません。バッチ承認を求めるとトークンを再導入してしまうため、トレードオフとして頭に入れておきます。単一パッケージの本レシピでは、この粒度がちょうど噛み合います。

PR マージ起点のハードニング

ここまでの部品を、PR マージ起点のリリースモデルとして束ねます。狙いは前述のとおり、ワークフローの変更が、レビューを経ずに公開を起動できないようにすることです。

公開ワークフローは pull_request.closed で起動し、if 条件で「マージ済みかつ Type: Release ラベルあり」に絞られます。そして release Environment が refs/pull/*/merge に制限されているため、デプロイは PR のマージ ref を通じてしか保護 Environment に入れません。結果として、ひとつのリリースに次のすべてが必要になります。

  • バージョン bump PR が作られる
  • その PR がソースレビューを受ける
  • PR に Type: Release ラベルが付く
  • PR が main へマージされる
  • デプロイが PR マージ ref 経由で release Environment を対象にする
  • メンテナが Environment デプロイを承認する
  • npm Trusted Publishing が OIDC 交換を受理する
  • npm が受け取るのは npm stage publish だけ
  • メンテナが staged パッケージを MFA で別途承認する

この refs/pull/*/merge の挙動は、机上の設計に留めず実地で検証しています。同一リポジトリの Type: Release PR をマージしたところ、ワークフローは release Environment の承認ゲートに到達し、承認後に npm stage publish が成功しました。続いて staged パッケージを検査・承認して live へ昇格させ、npm viewnpx で確認できています(検証結果は issue #5 のコメントに記録)。

万一 GitHub がこの評価挙動を変えたり、Environment の branch ルールでデプロイが拒否されたりした場合は、main への push トリガーに切り替え、保護 Environment に入る前にマージ済み PR のメタデータを検証する preflight ジョブを足す、というフォールバックを用意しておきます。

リリース PR の生成を自動化する

ハードニングモデルのうち、バージョン bump PR と Type: Release ラベルの付与を手作業のまま残すと、リリースのたびに人間がミスをしうる箇所が残ります。そこで issue #7 では、azu さんのモデルの自動化側を担う create-release-pr.yml を追加しました。

このワークフローは workflow_dispatchrelease_typepatchminormajor)を受け取り、バージョンを bump して同一リポジトリの branch を push し、Type: Release ラベル付きの PR を開きます。権限は contents: writepull-requests: write だけです。

on:
  workflow_dispatch:
    inputs:
      release_type:
        description: "Version bump type"
        required: true
        default: patch
        type: choice
        options:
          - patch
          - minor
          - major
 
permissions:
  contents: write
  pull-requests: write

肝心なのは、PR を必ず同一リポジトリの branch から開く点です。fork 由来の pull_request 実行には id-token: write が渡らないため、fork から開くと下流の OIDC Trusted Publishing が成立しません。ここを取り違えると、自動化したつもりが公開フローを丸ごと壊します。

実行ごとの安全のため、ワークフローは事前チェックを重ねています。Type: Release ラベルの存在確認、未処理の Type: Release PR がないことの確認、同名リリース branch がないことの確認を経てから、ようやく bump・push・PR 作成へ進みます。

      - name: Bump package version
        id: bump
        run: |
          npm version "${{ inputs.release_type }}" --no-git-tag-version
          VERSION=$(node -p 'require("./package.json").version')
          echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
        working-directory: packages/hello-cli

生成された PR は自動では公開されません。あくまでメンテナがレビューしてマージするものであり、そのマージこそが publish-hello-cli.yml を起動します。つまり自動化は「リリースの起点を作る」ところまでで、「公開を確定させる」のは依然として人間のレビューとマージ、そして MFA 承認です。自動化と多層防御は両立する、というのがこの設計の落としどころです。

この自動生成も実地で確認済みです。PR #10 のマージ後に手動で release_type=patch を流したところ、同一リポジトリの release/hello-cli-0.1.3 branch から Type: Release ラベル付きの PR #11 が生成され、差分は package.jsonpackage-lock.json のバージョンだけでした(検証結果は issue #7 のコメントに記録)。

provenance とその限界

公開が live になったら、npmjs.com のパッケージページで provenance を確認します。ページには次が表示されます。

  • Built and signed on GitHub Actions
  • Source Commitgithub.com/codenote-net/cli-distribution-recipes のコミットへリンク)
  • Build File.github/workflows/publish-hello-cli.yml へリンク)
  • Public Ledger(透明性ログのエントリ)

ただし provenance が証明するのは「来歴(origin)」であって、「ビルド時の完全性」ではありません。ここを誤解しないことが重要です。仮にビルド環境が汚染されていても、そこから出たパッケージには有効な provenance 署名が付いてしまいます。provenance は「どこから来たか」を保証するだけで、「中身が汚染されていないか」までは保証しません。ビルド時の完全性まで踏み込むなら、隔離ビルド(SLSA Build L3 以上)が別途必要です。

また provenance が生成されるのは公開リポジトリ・公開パッケージのときだけです。本レシピのリポジトリは public なので機能しますが、private リポジトリでは Trusted Publishing を使っても provenance は付きません。

検証

npm stage approve の前は、パッケージは staged であって live ではありません。npx でも npm install -g でも取得できないので、まず staged の状態を検査します。

npm stage list @codenote-net/hello-cli
npm stage view <id>
npm stage download <id>

承認して live へ昇格させたら、メタデータとインストール経路を確認します。

npm view @codenote-net/hello-cli version
npm view @codenote-net/hello-cli dist
 
npx @codenote-net/hello-cli
npm install -g @codenote-net/hello-cli
codenote-hello

期待出力はこれです。

Ohayou gozaimasu, Konnichiwa, Konbanwa!

最後に npmjs.com のページで、provenance が codenote-net/cli-distribution-recipes.github/workflows/publish-hello-cli.yml にリンクしていることを確認すれば、端から端までの検証は完了です。

限界と既知の制約

このレシピも万能ではありません。配る前に、制約を明示的に把握しておきます。

  • provenance は来歴の証明であって、ビルド時完全性の証明ではない: 汚染されたビルドにも有効な署名が付きうる。完全性には隔離ビルドが要る。
  • provenance は public 限定: private リポジトリ・private パッケージでは付かない。
  • 再利用可能ワークフローへの移設に注意: 公開ステップを reusable workflow に移すと、npm の Trusted Publishing は呼び出し元(caller)のワークフローファイルを参照する必要がある。
  • Trusted Publisher はパッケージにつき 1 構成のみ。self-hosted runner は非対応。
  • ステージ承認はパッケージ単位: 大きな monorepo ではバッチ化しづらく、バッチ承認はトークン再導入を招く。
  • 要件: npm 11.5.1 以上(ステージ公開は 11.15.0 以上)、Node.js 22.14 以上。

これらは欠陥というより、設計上のトレードオフです。要件が前提を超えたら、隔離ビルドや別のリリースモデルへ段階的に引き上げる合図だと捉えてください。

まとめ

長期トークンで npm publish を回す時代は終わりました。本記事で組み立てたのは、その新しい既定値です。要点を整理します。

  • 公開は GitHub Actions の OIDC を使う Trusted Publishing で認証し、NPM_TOKEN をゼロにする。公開リポジトリなら provenance も自動で付く。
  • npm 側の Trusted Publisher には npm stage publish だけを許可し、npm publish を与えない。CI はステージまで、live 昇格は MFA を要する別操作に分ける。
  • release Environment を required reviewers と refs/pull/*/merge で保護し、公開を「レビュー済み PR のマージ」起点に縛る。
  • リリース PR の生成は create-release-pr.yml で自動化しつつ、公開の確定は人間のレビュー・マージ・MFA 承認に残す。
  • これらが束になって、リポジトリ write 権限ひとつでは live な npm パッケージを作れない多層防御が成立する。
  • provenance は来歴を保証するがビルド時完全性は保証しない。そこまで要るなら隔離ビルドへ進む。

リポジトリ書き込みから live リリースへの直結を断つ。たったそれだけのために、独立した関門をいくつも重ねる。手数は増えますが、2026 年の npm エコシステムで公開チャネルを預かるなら、これくらいの多層防御を既定にしておく価値があります。

以上、npm 公開を Trusted Publishing で固める設計を、現場からお送りしました。

参考情報