はじめに
GitHub Actions から Azure へは、OIDC を使うと client secret なしで認証できます。Microsoft はこの仕組みを Workload Identity Federation と呼んでいます。OIDC(OpenID Connect)は、ログインした相手が誰かを安全に伝えるための仕組みです。
本記事では、GitHub Actions から Terraform の plan と apply を Azure に対して実行するために、OIDC で認証する構成を作った手順を紹介します。途中でつまずいた点もあわせてまとめました。
ここに書いた手順は、2026 年 10 月の時点で実際に動かして確かめたものです。Azure CLI の挙動や公式ドキュメントの記載は変わるため、最新の情報もあわせて確認してください。
人が外部の ID で Google Cloud にサインインする仕組みは、Workforce Identity Federation という別のものです。本記事が扱うのは処理(ワークロード)側だけです。Google Cloud 側の同じ構成は別記事にします。
本記事のまとめ
- フェデレーション資格情報の subject は完全一致である。起動するイベントごとに 1 本ずつ作る。
- Terraform だけなら
azure/loginは要らない。ARM_*の環境変数を渡す。 - ジョブに
environment:を書くと subject が変わり、認証が通らなくなる。 - plan だけでも、state を置くストレージには書き込みのロールが要る。
- Microsoft Graph の管理者同意は、コマンドが成功を返しても付与されていないことがある。

GitHub Actions から Azure へ OIDC で認証する構成
設定する場所は 3 つです。Entra ID が「どの GitHub のトークンを信頼するか」を決め、Azure のロールが「何をしてよいか」を決めます。
| 場所 | 作るもの | 役割 |
| Entra ID | アプリ登録、フェデレーション資格情報、サービスプリンシパル | GitHub が発行したトークンを受け取り、Azure 用のトークンに交換する |
| Azure | サービスプリンシパルへのロール割り当て | state の読み書きと、リソースの操作を許可する |
| GitHub Actions | id-token: write と環境変数 | GitHub のトークンを要求し、Terraform に渡す |
私の構成では、plan は PR を作ると自動で走ります。apply は、PR に terraform apply dev とコメントして GitHub Actions を起動します。この「PR とコメント」の 2 つの起動方法が、後で説明する subject の数に関わります。
Entra ID で CI 用のアプリを登録する
まず Entra ID に、GitHub Actions が名乗るためのアプリを登録します。client secret は 1 つも作りません。サインインできるのは自分のテナントのユーザーだけ(単一テナント)にしました。
アプリ登録と同時に、エンタープライズアプリケーション(サービスプリンシパル)も作ります。Azure のロールや Microsoft Graph の権限を受け取るのは、アプリ登録ではなくサービスプリンシパルのほうです。
初回は Terraform ではなく、Azure CLI のスクリプトで作りました。CI の認証に使う ID を CI で作ることはできないためです。後から Terraform に取り込んだ経緯は、Azure ADアプリ登録をTerraform importで管理するで紹介しています。

フェデレーション資格情報は subject ごとに作る
フェデレーション資格情報は、「GitHub のこのトークンなら信頼する」という条件です。subject は、そのトークンがどのリポジトリのどのイベントから出たかを示す値です。照合は subject の完全一致で、ワイルドカードは使えません。そのため、起動するイベントごとに 1 本ずつ作ります。
locals {
federated_credentials = {
# PR での plan(pull_request イベント)
pull_request = "repo:${var.github_repository}:pull_request"
# PR へのコメントで起動する apply(main の ref で実行される)
main = "repo:${var.github_repository}:ref:refs/heads/main"
}
}
resource "azuread_application_federated_identity_credential" "github_actions" {
for_each = local.federated_credentials
application_id = azuread_application_registration.github_actions.id
display_name = "github-actions-${each.key}"
issuer = "https://token.actions.githubusercontent.com"
subject = each.value
audiences = ["api://AzureADTokenExchange"]
}PR へのコメントで起動するワークフローは、PR のブランチではなく main の ref で実行されます。そのため subject は ref:refs/heads/main になり、PR 用の資格情報では通りません。
subject の書き方にも注意が要ります。Microsoft Learn の Azure CLI の例では、PR の subject が pull-request(ハイフン)でした。しかし GitHub が実際に発行するトークンは pull_request(アンダースコア)です。私はトークン側の書き方に合わせ、実際に CI で認証が通ることを確かめました。
ジョブに environment: を書くと subject が変わる
GitHub Actions のジョブに environment: を書くと、subject は repo:OWNER/REPO:environment:名前 に置き換わります。上の 2 本のどちらにも一致しなくなるので、私は GitHub の Environments を使わないことにしました。
Google Cloud 向けに作っていた同じ形のワークフローは environment: を使っていたので、そのまま移すと認証が壊れるところでした。Google Cloud 側はリポジトリ名の属性で照合しているため、environment: があっても影響を受けません。

ワークフローは ARM_* の環境変数で認証する
ワークフロー側で必要なのは、id-token: write の権限と環境変数だけです。Terraform を動かすだけなら azure/login は要りません。次のコードは、実際のワークフローから認証に関わる部分だけを抜き出したものです。
permissions:
contents: read
id-token: write # GitHub にトークンを要求するために必須
pull-requests: write
env:
ARM_CLIENT_ID: ${{ secrets.azure_client_id }}
ARM_TENANT_ID: ${{ secrets.azure_tenant_id }}
ARM_SUBSCRIPTION_ID: ${{ secrets.azure_subscription_id }}
ARM_USE_OIDC: "true"
ARM_USE_AZUREAD: "true" # state を置く backend の認証用当初は azure/login を使うつもりでした。しかし実装を読むと、このアクションは Azure CLI にログインするためのもので、Terraform が読む ARM_* の環境変数を設定していませんでした。Azure CLI を併用するときだけ足します。なお、調べた時点の azure/login の README では v3 が推奨で、v2 はセキュリティ修正だけを受ける保守モードでした。
もう 1 つの注意点は backend です。ARM_USE_OIDC は provider の設定で、state を置く backend には効きません。私は ARM_USE_AZUREAD=true を CI の環境変数でだけ渡しました。backend.tf に書くとローカルの実行も Entra ID 認証になり、手元でも state 用のロールが必要になるためです。
元の backend.tf は、ストレージのアクセスキーを取得して認証する方式(Access Key Lookup)でした。azurerm のドキュメントでは、この方式は新しく作る構成には推奨されていません。backend.tf に resource_group_name が残っていても、ARM_USE_AZUREAD=true を渡した CI では Entra ID 認証が優先されました。
Azure のロールは state 用とリソースグループ用に絞る
サービスプリンシパルに付けたロールは 2 つです。サブスクリプション全体への権限は付けていません。
| ロール | 範囲 | 理由 |
| Storage Blob Data Contributor | state を置くコンテナー | state の読み書きとロック |
| Contributor | 管理対象のリソースグループ | リソースの作成・変更 |
plan しかしない場合でも、state 用のロールは読み取り専用では足りません。plan も state のロックを取るため、書き込みのできるロールが要ります。
ロールをリソースグループに絞ったことで、別の設定も必要になりました。Terraform は起動時にサブスクリプションへリソースプロバイダーを登録しようとしますが、その権限がありません。CI では ARM_SKIP_PROVIDER_REGISTRATION=true を渡して、登録を省いています。


Microsoft Graph の権限の管理者同意でつまずいた
Entra ID 自体も Terraform で管理しています。そのため CI 用のサービスプリンシパルには、Microsoft Graph の読み取り権限を 3 つ付けました。
Application.Read.AllGroup.Read.AllUser.Read.All
この 3 つは、アプリ単独で動くアプリケーション権限です。ユーザーの代理で動く「委任されたアクセス許可」とは別物です。この管理者同意で 2 回つまずきました。
1 つ目は必要なロールです。アプリケーション権限の管理者同意には、Privileged Role Administrator 以上のディレクトリロールが要ります。Application Administrator や Cloud Application Administrator では、Microsoft Graph のアプリケーション権限に同意できません。
2 つ目は Azure CLI の挙動です。az ad app permission admin-consent は成功を返したのに、権限は 1 つも付与されていませんでした。実行したのは全体管理者なので、ロールは足りています。最終的に、Microsoft Graph の API へ直接付与を依頼して解決しました。
az rest --method POST \
--url "https://graph.microsoft.com/v1.0/servicePrincipals/${SP_OBJECT_ID}/appRoleAssignments" \
--headers "Content-Type=application/json" \
--body "{\"principalId\":\"${SP_OBJECT_ID}\",\"resourceId\":\"${GRAPH_SP_OBJECT_ID}\",\"appRoleId\":\"${ROLE_ID}\"}"なお、az ad app permission add を実行すると「az ad app permission grant を実行してください」と案内されます。このコマンドは委任されたアクセス許可用で、アプリケーション権限には効きません。案内に従っても今回の問題は解決しませんでした。

az ad app permission admin-consent では付与されず、Graph の appRoleAssignments へ直接 POST して解決した結果まとめ
GitHub Actions から Azure へ、client secret を使わずに Terraform の plan と apply を実行できるようになりました。GitHub Secrets に置いているのは、秘密ではない ID だけです。
- フェデレーション資格情報は subject の完全一致である。PR 用とコメント起動用(main の ref)の 2 本を作り、ジョブに
environment:は書かない。 - ワークフローは
ARM_*の環境変数で認証し、backend にはARM_USE_AZUREAD=trueを別に渡す。 - ロールは state 用とリソースグループ用に絞る。Graph の管理者同意は、コマンドの成功表示ではなく実際の付与状況で確かめる。

