GitHub Actions에서 WIF 사용하기
GitHub Actions에서 WIF 사용하기
모든 GitHub Actions 워크플로우 실행은 GitHub의 호스팅 발급자 https://token.actions.githubusercontent.com에서 서명된 아이덴티티 토큰을 요청할 수 있어요. Workload Identity Federation으로 워크플로우는 그 토큰을 수명이 짧은 Anthropic 접근 토큰으로 교환하므로, 저장소에 ANTHROPIC_API_KEY 시크릿을 저장하지 않고도 CI 작업이 Claude API를 호출할 수 있어요.
토큰의 sub 클레임은 저장소와 트리거 컨텍스트를 인코딩해요. 브랜치로의 push는 repo:<owner>/<repo>:ref:refs/heads/<branch> 형태이고, 풀 리퀘스트 실행은 repo:<owner>/<repo>:pull_request, 환경 게이트 배포는 repo:<owner>/<repo>:environment:<name>을 사용해요. 내 페더레이션 규칙은 이 클레임(그리고 repository_owner, ref 같은 다른 것들)에 일치해 어떤 워크플로우 실행이 인증될 수 있는지 결정해요.
출처: 문서
본문
사전 요구사항
- WIF 개념에 익숙해야 해요. 서비스 계정, 페더레이션 발급자, 페더레이션 규칙.
- 워크플로우 파일을 편집하고
id-token: write권한을 부여할 수 있는 GitHub 저장소. - Anthropic 조직의 Claude Console에서 서비스 계정, 페더레이션 발급자, 페더레이션 규칙을 만들 권한.
- 내 Anthropic 조직 ID. Claude Console의 Settings → Organization에서 찾을 수 있어요.
워크플로우 구성하기
GitHub는 명시적으로 요청하는 작업에만 아이덴티티 토큰을 발행해요. 워크플로우나 작업 수준에서 id-token: write 권한을 추가하세요:
permissions:
id-token: write
contents: read
작업 안에서 러너는 두 환경 변수 ACTIONS_ID_TOKEN_REQUEST_URL과 ACTIONS_ID_TOKEN_REQUEST_TOKEN을 노출해요. 요청 토큰을 베어러 자격 증명으로, 선택한 audience를 쿼리 파라미터로 요청 URL을 호출한 뒤, 반환된 JSON Web Token(JWT)을 파일에 쓰세요:
- name: Fetch GitHub OIDC token
run: |
curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://api.anthropic.com" \
| jq -r .value > /tmp/gha-jwt
JavaScript를 선호한다면 actions/github-script가 core.getIDToken(audience)를 통해 같은 능력을 노출해요:
- name: Fetch GitHub OIDC token
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const token = await core.getIDToken('https://api.anthropic.com');
fs.writeFileSync('/tmp/gha-jwt', token);
디코딩된 토큰은 워크플로우 실행을 설명하는 클레임을 나른다. 내 페더레이션 규칙은 이에 일치해요:
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:your-org/your-repo:ref:refs/heads/main",
"aud": "https://api.anthropic.com",
"repository": "your-org/your-repo",
"repository_owner": "your-org",
"ref": "refs/heads/main",
"sha": "abc123...",
"workflow": "CI",
"actor": "octocat",
"event_name": "push"
}
sub 형식의 전체 목록은 GitHub의 OIDC subject 클레임 참조를 참고하세요.
Anthropic 구성하기
Claude Console에서 Settings → Workload identity를 열고 Connect workload를 클릭한 뒤 GitHub Actions 타일을 선택하세요. 마법사가 발급자 등록, 서비스 계정 만들기, 페더레이션 규칙 만들기를 안내해요.
마법사가 이 리소스들을 만들어 줘요. 마법사에 입력하든 Admin API로 보내든 다음 값을 사용하세요:
페더레이션 발급자: GitHub는 OIDC discovery 문서와 JWKS를 공개적으로 게시하므로 discovery 모드를 사용해요. Anthropic은 GitHub가 키를 회전하면 자동으로 키를 갱신해요.
{
"name": "github-actions",
"issuer_url": "https://token.actions.githubusercontent.com",
"jwks": { "type": "discovery" }
}
페더레이션 규칙: 신뢰하려는 워크플로우 실행에만 일치시키세요. 이 클레임들을 안전하게 범위 지정하는 방법은 어떤 워크플로우가 인증할 수 있는지 제한하기를 참고하세요.
{
"name": "gha-main",
"issuer_id": "fdis_...",
"match": {
"subject_prefix": "repo:your-org/your-repo:ref:refs/heads/main",
"audience": "https://api.anthropic.com",
"claims": {
"repository_owner": "your-org"
}
},
"target": {
"type": "service_account",
"service_account_id": "svac_..."
},
"workspace_id": "wrkspc_...",
"oauth_scope": "workspace:developer",
"token_lifetime_seconds": 600
}
워크로드가 허용하는 만큼 구체적으로 하세요. 같은 저장소의 여러 이벤트 타입에 규칙이 일치해야만(그래야만) subject_prefix를 repo:your-org/your-repo:*(claims.ref 제약과 함께)로 느슨하게 하세요. sub의 끝자락 세그먼트가 ref:..., environment:..., pull_request 이벤트 사이에서 달라지기 때문이에요.
토큰 획득과 사용
작업에 페더레이션 환경 변수를 설정하고 SDK를 평소처럼 호출하세요. Anthropic()은 ANTHROPIC_IDENTITY_TOKEN_FILE을 읽고, 첫 요청에서 JWT를 교환하며, 만료 전에 접근 토큰을 자동으로 갱신해요.
name: Call Claude
on: push
permissions:
id-token: write
contents: read
jobs:
call-claude:
runs-on: ubuntu-latest
env:
ANTHROPIC_FEDERATION_RULE_ID: fdrl_...
ANTHROPIC_ORGANIZATION_ID: 00000000-0000-0000-0000-000000000000
ANTHROPIC_SERVICE_ACCOUNT_ID: svac_...
ANTHROPIC_WORKSPACE_ID: wrkspc_... # required when the rule covers multiple workspaces
ANTHROPIC_IDENTITY_TOKEN_FILE: /tmp/gha-jwt
steps:
- uses: actions/checkout@v5
- name: Fetch GitHub OIDC token
run: |
curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://api.anthropic.com" \
| jq -r .value > "$ANTHROPIC_IDENTITY_TOKEN_FILE"
- name: Run your script
run: |
pip install anthropic
python your_script.py
JWT=$(cat /tmp/gha-jwt)
RESPONSE=$(curl -sS https://api.anthropic.com/v1/oauth/token \
-H "content-type: application/json" \
--data @- <<JSON
{
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion": "$JWT",
"federation_rule_id": "$ANTHROPIC_FEDERATION_RULE_ID",
"organization_id": "$ANTHROPIC_ORGANIZATION_ID",
"service_account_id": "$ANTHROPIC_SERVICE_ACCOUNT_ID",
"workspace_id": "$ANTHROPIC_WORKSPACE_ID"
}
JSON
)
ACCESS_TOKEN=$(echo "$RESPONSE" | jq -r .access_token)
curl https://api.anthropic.com/v1/messages \
-H "authorization: Bearer $ACCESS_TOKEN" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d '{
"model": "claude-opus-5-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello, Claude"}]
}' | jq -r '.content[] | select(.type == "text") | .text'
import anthropic
# Reads ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID,
# ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID, and ANTHROPIC_IDENTITY_TOKEN_FILE
# from the job environment.
client = anthropic.Anthropic()
message = client.messages.create(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello, Claude"}],
)
print(next(block.text for block in message.content if block.type == "text"))
import Anthropic from "@anthropic-ai/sdk";
// Reads ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID,
// ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID, and ANTHROPIC_IDENTITY_TOKEN_FILE
// from the job environment.
const client = new Anthropic();
const message = await client.messages.create({
model: "claude-opus-5-5",
max_tokens: 1024,
messages: [{ role: "user", content: "Hello, Claude" }]
});
for (const block of message.content) {
if (block.type === "text") {
console.log(block.text);
}
}
// Reads ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID,
// ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID, and ANTHROPIC_IDENTITY_TOKEN_FILE
// from the job environment.
client := anthropic.NewClient()
message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
Model: anthropic.ModelClaudeOpus5_5,
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Hello, Claude")),
},
})
if err != nil {
panic(err)
}
for _, block := range message.Content {
if textBlock, ok := block.AsAny().(anthropic.TextBlock); ok {
fmt.Println(textBlock.Text)
break
}
}
AnthropicClient client = AnthropicOkHttpClient.fromEnv();
var message = client.messages().create(MessageCreateParams.builder()
.model(Model.CLAUDE_OPUS_5_5)
.maxTokens(1024)
.addUserMessage("Hello, Claude")
.build());
IO.println(message.content());
// Reads ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID,
// ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID, and ANTHROPIC_IDENTITY_TOKEN_FILE
// from the job environment.
using var client = new AnthropicClient();
var message = await client.Messages.Create(new()
{
Model = Model.ClaudeOpus5_5,
MaxTokens = 1024,
Messages = [new() { Role = Role.User, Content = "Hello, Claude" }],
});
foreach (var block in message.Content)
{
if (block.Value is TextBlock textBlock)
{
Console.WriteLine(textBlock.Text);
}
}
# Reads ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID,
# ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID, and ANTHROPIC_IDENTITY_TOKEN_FILE
# from the job environment.
ant messages create \
--model claude-opus-5-5 \
--max-tokens 1024 \
--message '{role: user, content: "Hello, Claude"}'
use Anthropic\Client;
// Reads ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID,
// ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID, and ANTHROPIC_IDENTITY_TOKEN_FILE
// from the job environment.
$client = new Client();
$message = $client->messages->create(
model: 'claude-opus-5-5',
maxTokens: 1024,
messages: [['role' => 'user', 'content' => 'Hello, Claude']],
);
$textBlock = array_find($message->content, static fn ($block): bool => $block->type === 'text');
echo $textBlock->text, PHP_EOL;
require "anthropic"
# Reads ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID,
# ANTHROPIC_SERVICE_ACCOUNT_ID, ANTHROPIC_WORKSPACE_ID, and ANTHROPIC_IDENTITY_TOKEN_FILE
# from the job environment.
client = Anthropic::Client.new
message = client.messages.create(
model: "claude-opus-5-5",
max_tokens: 1024,
messages: [{role: "user", content: "Hello, Claude"}]
)
puts message.content.find { it.type == :text }.text
GitHub가 발행한 각 아이덴티티 토큰은 발행 후 약 5분에 만료돼요. 토큰 요청 엔드포인트(ACTIONS_ID_TOKEN_REQUEST_URL)는 전체 작업 동안 유효하므로 언제든 새 토큰을 가져올 수 있어요. SDK는 첫 사용 시 토큰을 교환하고 결과 Anthropic 접근 토큰을 캐시해요. Anthropic 토큰의 수명보다 길게 실행되는 작업의 경우 SDK가 매 갱신마다 ANTHROPIC_IDENTITY_TOKEN_FILE을 다시 읽으므로, 가져오기 단계를 주기적으로(또는 백그라운드 루프로 감싸서) 다시 실행해 파일을 최신으로 유지하세요. 또는 SDK에 파일 경로 대신 ACTIONS_ID_TOKEN_REQUEST_URL을 직접 호출하는 토큰 공급자 콜백을 전달하세요.
설정 검증하기
성공한 교환은 sk-로 시작하는 access_token과 초 단위의 expires_in 값을 반환해요. 거부된 교환은 어떤 검사가 실패했든 고정 메시지 Authentication failed와 함께 불투명한 401 authentication_error를 반환해요. 대부분의 경우 거부 이유는 인증 기록 페이지의 시도 항목에 기록되고, 실패한 교환 문제 해결이 검사들을 순서대로 다뤄요. 가장 흔한 GitHub Actions 측 원인은 sub 클레임 형식이 일치하지 않는 것(끝자락 세그먼트가 ref:..., environment:..., pull_request 이벤트 사이에서 달라짐)이고, 기록 항목은 match_subject_prefix 이유를 보여줘요.
어떤 워크플로우가 인증할 수 있는지 제한하기
경고
repo:your-org/*만의subject_prefix는 조직의 모든 저장소와 일치하고,ref제약이 없으면 포크에서 트리거된pull_request실행에도 일치해요. 일치하는 저장소에 풀 리퀘스트를 열 수 있는 사람이라면 누구나 페더레이션 Anthropic 토큰을 얻을 수 있어요.
규칙의 match 블록을 사용 사례에 맞는 가장 좁은 범위로 잠그세요:
- 단일 저장소로 고정:
subject_prefix: "repo:your-org/your-repo:*"을 사용해 조직의 다른 저장소가 일치하지 않게 하세요. - 보호된 브랜치로 고정:
claims아래에"ref": "refs/heads/main"(또는 내 릴리스 브랜치)을 추가해 풀 리퀘스트 실행과 기능 브랜치가 일치하지 않게 하세요. - 소유자를 명시적으로 고정:
sub파싱 경계 사례에 대한 심층 방어 검사로claims아래에"repository_owner": "your-org"을 추가하세요. - 배포 환경으로 고정: 배포 작업의 경우
subject_prefix: "repo:your-org/your-repo:environment:production"에 일치시키고 GitHub에서 필수 검토자로 그 환경을 게이트하세요.
다음 단계
- Workload Identity Federation — 전체 설정 워크스루, 환경 변수, 자격 증명 우선순위.
- 인증 (Authentication) — 페더레이션이 API 키와 어떻게 비교되는지.