GitHub Actions: OIDC 免密鑰部署到 AWS / GCP / Azure
標籤:#DevOps #GitHub Actions #OIDC #雲端部署 #安全
不再儲存長期 AWS Access Key / GCP Service Account JSON / Azure 密碼,改用 OIDC 換短期 token 進行雲端部署
目錄
- 為什麼要用 OIDC?
- OIDC 運作原理
- GHA OIDC Token 結構
- AWS 部署實作
- GCP 部署實作
- Azure 部署實作
- sub claim 設計策略
- 常見錯誤排查
- 最佳實踐
- 常見問題
- 總結
為什麼要用 OIDC?
傳統做法的問題
# ❌ 傳統:長期 access key 存在 GitHub Secrets
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-1
痛點:
- 長期憑證一旦外洩:rotation 是惡夢,所有 repo 都要改
- 權限太大:通常會給 admin 或非常寬的權限,難以最小化
- 稽核困難:CloudTrail 看到的是同一個 user / role 反覆呼叫,看不出來「是哪個 repo / workflow 在做事」
- 離職員工的金鑰:很容易遺漏不停用
- GitHub Secrets 本身的 trust boundary:任何 repo admin 都看不到,但能在 workflow 內使用 → branch protection 沒設好就有風險
OIDC 的優勢
| 比較項目 | 長期 Access Key | OIDC |
|---|---|---|
| 憑證有效期 | 永久(直到 rotate) | 1 小時 |
| 儲存位置 | GitHub Secrets | 不需要儲存 |
| 稽核可追蹤性 | 中(看到 IAM user) | 高(JWT 包含 repo / branch / actor) |
| 權限收斂 | 困難 | 可以針對 repo / branch 設權限 |
| 金鑰 rotation | 手動 | 自動(每次 workflow 都拿新的) |
OIDC 運作原理
整體流程
┌─────────────────┐ ┌──────────────────┐
│ GitHub Actions │ │ Cloud Provider │
│ Runner │ │ (AWS/GCP/Azure) │
└────────┬────────┘ └─────────┬────────┘
│ │
│ 1. 跟 GitHub OIDC Provider 要 JWT │
│ ────────────────────────────────────► │
│ │
│ 2. 拿到簽名過的 JWT(含 repo/branch) │
│ │
│ 3. 把 JWT 送到 Cloud Provider │
│ ────────────────────────────────────► │
│ │
│ 4. Cloud 驗證:
│ - 簽名是否來自 GitHub
│ - sub claim 符合信任策略
│ - aud claim 正確
│ │
│ 5. 回傳短期 credentials │
│ ◄────────────────────────────────── │
│ │
│ 6. 用短期 credentials 呼叫 API │
│ ────────────────────────────────────► │
三個關鍵組件
-
GitHub OIDC Provider(
token.actions.githubusercontent.com)- 每個 GHA workflow 都能跟它要 JWT
- JWT 內含 repo、branch、ref、actor 等 claims
-
Cloud Provider 的信任設定
- 在 AWS/GCP/Azure 設定「信任 GitHub OIDC Provider」
- 指定哪些 JWT 可以換成哪個 IAM Role / Service Account
-
GHA 內的權限宣告
permissions: id-token: write # 允許跟 OIDC Provider 要 JWT contents: read
GHA OIDC Token 結構
實際 JWT decode 出來會像這樣:
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:my-org/my-repo:ref:refs/heads/main",
"aud": "https://github.com/my-org",
"ref": "refs/heads/main",
"sha": "abc123...",
"repository": "my-org/my-repo",
"repository_owner": "my-org",
"actor": "alice",
"workflow": "Deploy",
"head_ref": "",
"base_ref": "",
"event_name": "push",
"job_workflow_ref": "my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main",
"iat": 1700000000,
"exp": 1700003600
}
最常被用來做信任判斷的 claims
| Claim | 用途 |
|---|---|
sub |
主要識別字串,可組合 repo + branch + environment + workflow |
repository |
repo 全名(owner/name) |
repository_owner |
organization 或使用者名 |
ref |
git ref(refs/heads/main、refs/tags/v1) |
environment |
如果 job 指定了 environment,會出現 |
job_workflow_ref |
哪個 workflow 檔案觸發的 |
sub 的格式
sub 是字串,GitHub 根據 job 屬性組合而成:
| 觸發情境 | sub 格式 |
|---|---|
| Push 到 branch | repo:OWNER/REPO:ref:refs/heads/BRANCH |
| PR | repo:OWNER/REPO:pull_request |
| Tag | repo:OWNER/REPO:ref:refs/tags/TAG |
| Environment | repo:OWNER/REPO:environment:ENV |
| Workflow 從 reusable workflow 呼叫 | repo:OWNER/REPO:job_workflow_ref:OTHER_REPO/.github/workflows/X.yml@refs/heads/main |
信任策略可以用萬用字元(
*)和 wildcard 比對,但寫太寬會出大事(下面 sub claim 設計策略章節詳述)
AWS 部署實作
Step 1:建立 IAM OIDC Provider
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
AWS 現在已經自動驗證 thumbprint,寫一個假的也能跑,但建議放正確的(來自 GitHub 官方文件)
Step 2:建立 IAM Role 與信任策略
trust-policy.json:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}]
}
aws iam create-role \
--role-name github-actions-deploy \
--assume-role-policy-document file://trust-policy.json
aws iam attach-role-policy \
--role-name github-actions-deploy \
--policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
Step 3:在 GHA 中使用
name: Deploy to AWS
on:
push:
branches: [main]
permissions:
id-token: write # 必要!允許 GHA 拿 JWT
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: ap-northeast-1
role-session-name: gha-${{ github.run_id }}
- name: Deploy
run: |
aws s3 sync ./dist s3://my-bucket/
Step 4:更嚴格的信任策略(多 branch / environment)
允許 main + tags + production environment:
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": [
"repo:my-org/my-repo:ref:refs/heads/main",
"repo:my-org/my-repo:ref:refs/tags/v*",
"repo:my-org/my-repo:environment:production"
]
}
}
}
注意:
StringEquals不支援 wildcard,要用StringLike才能用*
GCP 部署實作
GCP 的設計用 Workload Identity Federation(WIF),透過 Pool + Provider 對應到 Service Account。
Step 1:建立 Workload Identity Pool
gcloud iam workload-identity-pools create "github-pool" \
--project="my-gcp-project" \
--location="global" \
--display-name="GitHub Actions Pool"
Step 2:建立 OIDC Provider
gcloud iam workload-identity-pools providers create-oidc "github-provider" \
--project="my-gcp-project" \
--location="global" \
--workload-identity-pool="github-pool" \
--display-name="GitHub" \
--attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
--attribute-condition="assertion.repository_owner == 'my-org'" \
--issuer-uri="https://token.actions.githubusercontent.com"
關鍵點:
attribute-mapping:把 GitHub JWT 的 claim 映射到 GCP 屬性attribute-condition:強烈建議加上,否則任何 GitHub repo 都可以用這個 provider!
Step 3:建立 Service Account 並授權
gcloud iam service-accounts create gha-deploy \
--project="my-gcp-project"
# 讓 GHA 可以 impersonate 這個 SA
gcloud iam service-accounts add-iam-policy-binding \
"gha-deploy@my-gcp-project.iam.gserviceaccount.com" \
--project="my-gcp-project" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/attribute.repository/my-org/my-repo"
# 給 SA 實際操作權限
gcloud projects add-iam-policy-binding my-gcp-project \
--member="serviceAccount:gha-deploy@my-gcp-project.iam.gserviceaccount.com" \
--role="roles/storage.admin"
Step 4:在 GHA 中使用
name: Deploy to GCP
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github-pool/providers/github-provider
service_account: gha-deploy@my-gcp-project.iam.gserviceaccount.com
- uses: google-github-actions/setup-gcloud@v2
- run: |
gsutil rsync -r ./dist gs://my-bucket/
GCP attribute-condition 範例
限定只能從特定 organization:
assertion.repository_owner == 'my-org'
限定特定 repo 與 branch:
assertion.repository == 'my-org/my-repo' && assertion.ref == 'refs/heads/main'
進階:特定 environment:
assertion.repository == 'my-org/my-repo' && assertion.environment == 'production'
attribute-condition用的是 CEL 表達式語言,可以做複雜邏輯
Azure 部署實作
Azure 用 Federated Identity Credentials 與 App Registration 對應。
Step 1:建立 App Registration
az ad app create --display-name "github-actions-deploy"
# 拿到 appId
az ad sp create --id <appId>
# 拿到 servicePrincipalObjectId
Step 2:建立 Federated Credential
fed-cred.json:
{
"name": "gha-main-branch",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:my-org/my-repo:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}
az ad app federated-credential create \
--id <appId> \
--parameters fed-cred.json
每種觸發情境要建一個 federated credential(branch / tag / environment 各一筆):
// production environment
{
"name": "gha-production-env",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:my-org/my-repo:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}
Step 3:授權給 App Registration
# Subscription 層級的 Contributor
az role assignment create \
--assignee <appId> \
--role Contributor \
--scope /subscriptions/<subscription-id>/resourceGroups/my-rg
Step 4:在 GHA 中使用
name: Deploy to Azure
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- run: |
az group list
az webapp deploy --src-path ./app.zip --name my-webapp -g my-rg
vars.*是 GitHub Actions Variables(非 secret),適合放 client-id 等公開資訊
sub claim 設計策略
sub 設計決定了誰可以 assume role,寫錯的後果嚴重。
危險的寫法
1. 過寬 — 任何 repo 都能用
"token.actions.githubusercontent.com:sub": "repo:*"
❌ 災難:任何 GitHub repo 都可以 assume 你的 role
2. 沒鎖定 owner
"token.actions.githubusercontent.com:sub": "repo:*/my-repo:*"
❌ 災難:有人在自己的帳號建 my-repo 就能 assume
3. 沒檢查 ref / environment
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:*"
⚠️ 風險:任何 branch 或 PR(包括外部 fork PR)都能 assume
推薦的寫法
Production 部署 — 只允許 environment
{
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:environment:production"
}
}
搭配 GitHub Environment 的 protection rules(reviewer approval、deploy 限制 branch),確保只有特定流程能觸發。
多種觸發 — 用陣列
{
"StringLike": {
"token.actions.githubusercontent.com:sub": [
"repo:my-org/my-repo:ref:refs/heads/main",
"repo:my-org/my-repo:ref:refs/tags/v*",
"repo:my-org/my-repo:environment:staging"
]
}
}
Reusable Workflow 場景 — 用 job_workflow_ref
如果你的 reusable workflow 集中放在 my-org/ci-templates,然後讓所有 repo 呼叫它,信任策略應該寫:
{
"StringLike": {
"token.actions.githubusercontent.com:job_workflow_ref": "my-org/ci-templates/.github/workflows/deploy.yml@refs/tags/v*"
}
}
這樣只信任「來自指定 reusable workflow + 指定 tag」,即使 caller repo 被攻破,攻擊者也無法直接 assume role。
AWS 要 IAM Provider 在
additional audience加上token.actions.githubusercontent.com:job_workflow_ref
設計決策表
| 需求 | 推薦 sub |
|---|---|
| 只在 main branch 部署 | repo:OWNER/REPO:ref:refs/heads/main |
| 只在 release tag 部署 | repo:OWNER/REPO:ref:refs/tags/v* |
| 用 GitHub Environment 保護 | repo:OWNER/REPO:environment:ENV |
| 信任 reusable workflow | job_workflow_ref 條件 |
| 整個 org 都可以(慎用) | repository_owner == 'my-org' 且加其他條件 |
常見錯誤排查
錯誤 1:Not authorized to perform sts:AssumeRoleWithWebIdentity
可能原因:
permissions: id-token: write沒設- trust policy 的
sub對不上 — 印出實際的 sub 看看 - OIDC Provider 沒建 — 確認 AWS IAM Provider 存在
aud不對 — AWS 預設要sts.amazonaws.com
Debug 技巧:在 workflow 內印出 JWT(用於 debug,正式不要做)
- name: Debug OIDC token
run: |
IDTOKEN=$(curl -sLS -H "User-Agent: actions/oidc-client" -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com")
echo "$IDTOKEN" | jq -R 'split(".") | .[1] | @base64d | fromjson'
env:
ACTIONS_ID_TOKEN_REQUEST_TOKEN: ${{ env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }}
ACTIONS_ID_TOKEN_REQUEST_URL: ${{ env.ACTIONS_ID_TOKEN_REQUEST_URL }}
錯誤 2:GCP Permission denied on resource
步驟:
- 檢查 workload identity provider 的 attribute-condition — 太嚴會拒絕,太鬆會放行錯誤的請求
- 檢查 SA 是否有
roles/iam.workloadIdentityUser:gcloud iam service-accounts get-iam-policy SA_EMAIL - 檢查 SA 是否有實際操作權限:
roles/storage.admin等 - member 路徑:
principalSet://...還是principal://...(差別在於是否 specific subject)
錯誤 3:Azure AADSTS70021
訊息:No matching federated identity record found
原因:subject 不匹配,例如 federated credential 設了 environment:production,但 workflow 沒寫 environment: production
檢查:
az ad app federated-credential list --id <appId>
對照 workflow 內的 environment: 或 ref。
最佳實踐
1. 每個用途一個 role,不要共用
github-actions-deploy-staging ← S3、ECS
github-actions-deploy-prod ← S3、ECS、嚴格 sub
github-actions-publish-ecr ← 只能 push ECR
理由:OIDC 的優勢就是「能精細劃分」,共用就失去意義。
2. 用 GitHub Environment 做最後一道牆
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # 觸發 environment protection
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::...:role/prod-deploy
Environment 可以設:
- Required reviewers:需要 approval 才能 deploy
- Wait timer:延遲 N 分鐘才能執行
- Deployment branches:只允許特定 branch
- Environment secrets / vars:這個 env 專屬的 secret
3. 不要把 cloud account ID 寫死在 workflow
# ❌
- with:
role-to-assume: arn:aws:iam::123456789012:role/deploy
# ✅ 用 vars(不需要保密,但可以集中管理)
- with:
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/deploy
4. 啟用 CloudTrail / Cloud Audit Logs
OIDC assume role 的呼叫會出現在 CloudTrail / Cloud Logs,可以據此設定告警:
- 異常 IP assume role
- 從非預期 repo 嘗試 assume
- 短時間多次失敗
5. Token 拿到後馬上用,不要傳遞
每次 workflow run 拿到的 token 是該 job 專屬的,不要透過 artifact / output 傳給另一個 job。
# ❌ 把 credentials 寫入檔案再上傳
- run: aws configure ... && tar czf creds.tgz ~/.aws
- uses: actions/upload-artifact@v4
with: { path: creds.tgz }
# ✅ 每個需要的 job 自己重新 assume
jobs:
deploy-s3:
steps:
- uses: aws-actions/configure-aws-credentials@v4
deploy-ecs:
steps:
- uses: aws-actions/configure-aws-credentials@v4 # 重新 assume
常見問題
Q1:permissions: id-token: write 跟一般 permissions 有什麼差?
id-token: write 是「允許這個 job 跟 GitHub OIDC Provider 要 JWT」的權限,和雲端權限無關。沒設這個 GHA 連 JWT 都拿不到,更別說 assume role。
Q2:能不能不用 GitHub Environment,直接寫死信任 branch?
可以,但失去 environment 的 protection。對於 production 強烈建議用 environment 多一層 reviewer。
Q3:如果攻擊者拿到我的 repo write 權限,OIDC 還安全嗎?
不安全。攻擊者可以改 workflow 把短期 credentials 撈出來。OIDC 的優勢是「沒有長期 credentials 可洩漏」,但 repo 寫權限本身就是攻擊面。
緩解:
- 用 protected branch + required reviews
- 用 Environment 的 required reviewer
- 用
job_workflow_ref信任 reusable workflow,讓敏感操作集中在 ci-templates repo
Q4:fork PR 會不會自動拿到 OIDC token?
外部 fork PR 不會拿到完整的 secret 與 OIDC token,GitHub 預設會阻擋。除非 maintainer 在 PR 上 approval 才會允許執行有 secret / OIDC 的 workflow。
Q5:可以同一個 workflow 部署到多個 cloud 嗎?
可以,只是要分別呼叫各自的 configure action:
- uses: aws-actions/configure-aws-credentials@v4
with: { role-to-assume: ... }
- uses: azure/login@v2
with: { client-id: ..., tenant-id: ... }
- uses: google-github-actions/auth@v2
with: { workload_identity_provider: ... }
每次都要 id-token: write 但 GHA 內部會分別請求對應 audience 的 JWT。
Q6:OIDC token 過期了會怎樣?
預設有效期 1 小時。長時間執行的 job(例如大型 build)如果超過 1 小時,要重新呼叫 configure action 換新的 credentials。某些 SDK 會自動 refresh(透過 STS / metadata),但是不要假設。
Q7:aud 一定要是 sts.amazonaws.com 嗎?
不一定。預設 AWS 用 sts.amazonaws.com,但你可以自訂(透過 audience 參數):
- uses: aws-actions/configure-aws-credentials@v4
with:
audience: my-custom-audience
role-to-assume: ...
對應 trust policy 要改:
"StringEquals": {
"token.actions.githubusercontent.com:aud": "my-custom-audience"
}
這在多 cloud / 多 tenant 環境可以隔離不同信任域。
總結
核心要點
- OIDC 換短期 token,取代長期 cloud credentials
- 三大雲都支援,但設計不同:AWS 用 IAM Role + trust policy,GCP 用 Workload Identity Federation,Azure 用 Federated Credentials
permissions: id-token: write必填,沒設拿不到 JWTsubclaim 設計是安全的關鍵,過寬等於沒設- 搭配 GitHub Environment 做 reviewer / branch protection,多一層防護
- 不要傳遞 credentials 給其他 job,每個 job 自己 assume
三大雲快速對照
| 步驟 | AWS | GCP | Azure |
|---|---|---|---|
| 1. 建 OIDC Provider | IAM OIDC Provider | Workload Identity Pool + Provider | Federated Credential |
| 2. 信任策略寫法 | Trust policy JSON | attribute-condition CEL | Federated Credential's subject |
| 3. 對應的身份 | IAM Role | Service Account | App Registration |
| 4. GHA 用的 Action | aws-actions/configure-aws-credentials |
google-github-actions/auth |
azure/login |
| 5. 預設 audience | sts.amazonaws.com |
自動 | api://AzureADTokenExchange |
速查 GHA 設定
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # 推薦
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/deploy
aws-region: ${{ vars.AWS_REGION }}
- run: aws s3 sync ./dist s3://${{ vars.S3_BUCKET }}/
建立日期:2026-05-25