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_URLACTIONS_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-scriptcore.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_prefixrepo: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에서 필수 검토자로 그 환경을 게이트하세요.

다음 단계

더 알아보기 (Learn more)