Google Cloud에서 WIF 사용하기

Google Cloud에서 WIF 사용하기

인스턴스 메타데이터 서버에 접근할 수 있는 모든 Google Cloud 컴퓨트 환경(Cloud Run, Cloud Functions, App Engine, Compute Engine(GCE), Workload Identity가 있는 GKE)은 연결된 서비스 계정에 대한 Google 서명 아이덴티티 토큰을 요청할 수 있어요. 토큰의 발급자는 https://accounts.google.com이고, Anthropic은 표준 OIDC discovery를 통해 직접 검증하며 추가 Google Cloud 구성이 필요 없어요.

이 가이드에서는 Google 발급자를 Anthropic에 등록하고, Google 서비스 계정을 Anthropic 서비스 계정에 바인딩하고, 워크로드가 아이덴티티 토큰을 수명이 짧은 Claude API 접근 토큰으로 교환하도록 하는 방법을 보여줄게요.

출처: 문서

본문

사전 요구사항

  • WIF 개념에 익숙해야 해요. 서비스 계정, 페더레이션 발급자, 페더레이션 규칙.
  • Cloud Run, Cloud Functions, App Engine, Compute Engine, 또는 GKE에서 실행되는 워크로드가 있는 Google Cloud 프로젝트.
  • 그 워크로드에 연결된 사용자 관리 Google 서비스 계정(Compute Engine 기본 서비스 계정이 아닌).
  • Anthropic 조직의 Claude Console에서 서비스 계정, 페더레이션 발급자, 페더레이션 규칙을 만들 권한.

Google Cloud 구성하기

Google은 연결된 서비스 계정이 있는 모든 워크로드에 아이덴티티 토큰을 자동으로 발행해요. 올바른 서비스 계정을 연결하는 것 외에 Google 쪽에서 켤 것이 없지만, 단계는 표준 컴퓨트와 GKE에서 약간 달라요.

Cloud Run, Cloud Functions, App Engine, GCE

서비스나 인스턴스에 전용 서비스 계정을 연결하세요:

gcloud run deploy my-service \
  --service-account [email protected]

워크로드 안에서 메타데이터 서버는 요청 시 서명된 아이덴티티 토큰을 반환해요. Anthropic 쪽에 등록하려는 audience로 요청하고, 응답이 email 클레임을 나르도록 format=full을 포함하세요:

GET http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.anthropic.com&format=full
Metadata-Flavor: Google

또는 gcloud CLI로:

gcloud auth print-identity-token \
  --audiences="https://api.anthropic.com" \
  --include-email

SDK 등가물은 토큰 획득과 사용에 나와 있어요.

디코딩된 토큰 페이로드는 이렇게 보여요:

{
  "iss": "https://accounts.google.com",
  "aud": "https://api.anthropic.com",
  "sub": "104892...",
  "azp": "104892...",
  "email": "[email protected]",
  "email_verified": true,
  "exp": 1775527120
}

sub 클레임은 Google 서비스 계정의 불투명한 숫자 고유 ID예요. email 클레임은 사람이 읽을 수 있는 서비스 계정 주소예요. 페더레이션 규칙에서 subemail 둘 다 일치시키세요.

Workload Identity가 있는 GKE

클러스터에서 Workload Identity를 활성화하고 iam.gke.io/gcp-service-account 어노테이션으로 Kubernetes 서비스 계정을 Google 서비스 계정에 바인딩하세요:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: inference-worker
  namespace: prod
  annotations:
    iam.gke.io/gcp-service-account: [email protected]

이 바인딩이 있으면 GKE 메타데이터 서버는 Cloud Run·GCE 경우와 동일한 Google 서명 토큰을 반환해요. 같은 https://accounts.google.com 발급자, 같은 email 클레임, 같은 가져오기 URL. 다음 섹션에서 정확히 같이 Anthropic을 구성하세요.

GKE의 format=full 토큰은 google.compute_engine.project_id, google.compute_engine.zone, google.compute_engine.instance_name 클레임도 추가로 포함하는데, 페더레이션 규칙의 condition 매처(예: claims.google.compute_engine.project_id == "my-project" 같은 CEL 표현식)에서 참조해 접근을 특정 클러스터나 노드 풀에 범위를 제한할 수 있어요.

참고 Kubernetes 서비스 계정을 Google 서비스 계정에 바인딩하고 싶지 않다면, GKE 파드는 대신 클러스터 자체의 OIDC 발급자(https://container.googleapis.com/v1/projects/PROJECT/locations/REGION/clusters/CLUSTER)를 프로젝티드 serviceAccountToken 볼륨과 함께 사용할 수 있어요. 그 경로는 accounts.google.com이 아니라 클러스터별 발급자를 사용해요. 그 패턴은 Kubernetes에서 WIF 사용하기를 참고하세요.

Anthropic 구성하기

Claude Console에서 Settings → Workload identity를 열고 Connect workload를 클릭한 뒤 Google Cloud 타일을 선택하세요. 마법사가 발급자 등록, 서비스 계정 만들기, 페더레이션 규칙 만들기를 안내해요.

마법사가 이 리소스들을 만들어 줘요. 마법사에 입력하든 Admin API로 보내든 다음 값을 사용하세요:

페더레이션 발급자: Google은 OIDC discovery 문서를 공개적으로 게시하므로 discovery 모드를 사용해요. 이 단일 발급자가 모든 Google Cloud 표면(Cloud Run, GCE, Cloud Functions, App Engine, Workload Identity가 있는 GKE)을 포괄해요. 워크로드를 발급자가 아니라 규칙으로 구분하세요.

{
  "name": "gcp",
  "issuer_url": "https://accounts.google.com",
  "jwks": { "type": "discovery" }
}

페더레이션 규칙: subemail 클레임 둘 다 일치시키세요. email은 읽을 수 있는 서비스 계정 주소이고, sub는 Google이 결코 재사용하지 않는 서비스 계정의 숫자 고유 ID예요. 그래서 고정하면 서비스 계정이 삭제된 후 같은 이메일로 새 것이 나중에 만들어져도 규칙을 보호해요. 고유 ID는 gcloud iam service-accounts describe SA_EMAIL --format='value(uniqueId)'로 찾으세요.

{
  "name": "gcp-inference-worker",
  "issuer_id": "fdis_...",
  "match": {
    "audience": "https://api.anthropic.com",
    "claims": {
      "sub": "104892101234567890123",
      "email": "[email protected]"
    }
  },
  "target": {
    "type": "service_account",
    "service_account_id": "svac_..."
  },
  "workspace_id": "wrkspc_...",
  "oauth_scope": "workspace:developer",
  "token_lifetime_seconds": 600
}

토큰 획득과 사용

Google Cloud 워크로드 안에서 메타데이터 서버에서 아이덴티티 토큰을 가져와 POST /v1/oauth/token에서 교환하고, 반환된 베어러 토큰으로 Claude API를 호출하세요. 각 Anthropic SDK는 메타데이터 서버에서 새 아이덴티티 토큰을 반환하는 토큰 공급자 callable을 제공하면 교환·갱신 루프를 자동으로 처리해요. 다음 예시에서 확인할 수 있어요.

# Fetch the Google-signed identity token from the metadata server
JWT=$(curl -sS -H "Metadata-Flavor: Google" \
  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.anthropic.com&format=full")

# Exchange it for an Anthropic access token
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)

# Call the Claude API
curl -sS 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 from Cloud Run"}]
  }' | jq -r '.content[] | select(.type == "text") | .text'
import os
import anthropic
import google.auth.transport.requests
import google.oauth2.id_token
from anthropic import WorkloadIdentityCredentials

AUDIENCE = "https://api.anthropic.com"


def fetch_google_identity_token() -> str:
    request = google.auth.transport.requests.Request()
    return google.oauth2.id_token.fetch_id_token(request, AUDIENCE)


client = anthropic.Anthropic(
    credentials=WorkloadIdentityCredentials(
        identity_token_provider=fetch_google_identity_token,
        federation_rule_id=os.environ["ANTHROPIC_FEDERATION_RULE_ID"],
        organization_id=os.environ["ANTHROPIC_ORGANIZATION_ID"],
        service_account_id=os.environ["ANTHROPIC_SERVICE_ACCOUNT_ID"],
        workspace_id=os.environ.get("ANTHROPIC_WORKSPACE_ID"),
    ),
)

message = client.messages.create(
    model="claude-opus-5-5",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Hello from Cloud Run"}],
)
print(next(block.text for block in message.content if block.type == "text"))
import Anthropic from "@anthropic-ai/sdk";
import { oidcFederationProvider } from "@anthropic-ai/sdk/lib/credentials/oidc-federation";

const METADATA_URL =
  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.anthropic.com&format=full";

async function fetchGoogleIdentityToken(): Promise<string> {
  const response = await fetch(METADATA_URL, {
    headers: { "Metadata-Flavor": "Google" }
  });
  return response.text();
}

const client = new Anthropic({
  credentials: oidcFederationProvider({
    identityTokenProvider: fetchGoogleIdentityToken,
    federationRuleId: process.env.ANTHROPIC_FEDERATION_RULE_ID!,
    organizationId: process.env.ANTHROPIC_ORGANIZATION_ID!,
    serviceAccountId: process.env.ANTHROPIC_SERVICE_ACCOUNT_ID,
    workspaceId: process.env.ANTHROPIC_WORKSPACE_ID,
    baseURL: "https://api.anthropic.com",
    fetch
  })
});

const message = await client.messages.create({
  model: "claude-opus-5-5",
  max_tokens: 1024,
  messages: [{ role: "user", content: "Hello from Cloud Run" }]
});
for (const block of message.content) {
  if (block.type === "text") {
    console.log(block.text);
  }
}
const audience = "https://api.anthropic.com"

googleIDToken := func(ctx context.Context) (string, error) {
	creds, err := idtoken.NewCredentials(&idtoken.Options{Audience: audience})
	if err != nil {
		return "", err
	}
	tok, err := creds.Token(ctx)
	if err != nil {
		return "", err
	}
	return tok.Value, nil
}

client := anthropic.NewClient(
	option.WithFederationTokenProvider(googleIDToken, option.FederationOptions{
		FederationRuleID: os.Getenv("ANTHROPIC_FEDERATION_RULE_ID"),
		OrganizationID:   os.Getenv("ANTHROPIC_ORGANIZATION_ID"),
		ServiceAccountID: os.Getenv("ANTHROPIC_SERVICE_ACCOUNT_ID"),
		WorkspaceID:      os.Getenv("ANTHROPIC_WORKSPACE_ID"),
	}),
)

message, err := client.Messages.New(context.TODO(), anthropic.MessageNewParams{
	Model:     anthropic.ModelClaudeOpus5_5,
	MaxTokens: 1024,
	Messages: []anthropic.MessageParam{
		anthropic.NewUserMessage(anthropic.NewTextBlock("Hello from Cloud Run")),
	},
})
if err != nil {
	panic(err)
}
for _, block := range message.Content {
	if textBlock, ok := block.AsAny().(anthropic.TextBlock); ok {
		fmt.Println(textBlock.Text)
		break
	}
}
HttpClient http = HttpClient.newHttpClient();
HttpRequest metadataRequest = HttpRequest.newBuilder()
        .uri(URI.create("http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.anthropic.com&format=full"))
        .header("Metadata-Flavor", "Google")
        .build();

IdentityTokenProvider fetchGoogleIdentityToken = () -> {
    try {
        return http.send(metadataRequest, HttpResponse.BodyHandlers.ofString()).body();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
};

AnthropicClient client = AnthropicOkHttpClient.builder()
        .federationTokenProvider(
                fetchGoogleIdentityToken,
                System.getenv("ANTHROPIC_FEDERATION_RULE_ID"),
                System.getenv("ANTHROPIC_ORGANIZATION_ID"),
                System.getenv("ANTHROPIC_SERVICE_ACCOUNT_ID"))
        .build();

var message = client.messages().create(MessageCreateParams.builder()
        .model(Model.CLAUDE_OPUS_5_5)
        .maxTokens(1024)
        .addUserMessage("Hello from Cloud Run")
        .build());

IO.println(message.content());
using Anthropic.Credentials;
// ...

var credentials = new WorkloadIdentityCredentials(new WorkloadIdentityOptions
{
    FederationRuleId = Environment.GetEnvironmentVariable("ANTHROPIC_FEDERATION_RULE_ID")!,
    OrganizationId = Environment.GetEnvironmentVariable("ANTHROPIC_ORGANIZATION_ID"),
    ServiceAccountId = Environment.GetEnvironmentVariable("ANTHROPIC_SERVICE_ACCOUNT_ID"),
    WorkspaceId = Environment.GetEnvironmentVariable("ANTHROPIC_WORKSPACE_ID"),
    IdentityTokenProvider = new MetadataTokenProvider(),
});
using var client = new AnthropicClient(new ClientOptions { Credentials = credentials });

var message = await client.Messages.Create(new()
{
    Model = Model.ClaudeOpus5_5,
    MaxTokens = 1024,
    Messages = [new() { Role = Role.User, Content = "Hello from Cloud Run" }],
});
foreach (var block in message.Content)
{
    if (block.Value is TextBlock textBlock)
    {
        Console.WriteLine(textBlock.Text);
    }
}

class MetadataTokenProvider : IIdentityTokenProvider
{
    private const string METADATA_URL =
        "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.anthropic.com&format=full";

    private static readonly HttpClient httpClient = new()
    {
        DefaultRequestHeaders = { { "Metadata-Flavor", "Google" } },
    };

    public async Task<string> GetIdentityTokenAsync(CancellationToken ct = default)
    {
        return await httpClient.GetStringAsync(METADATA_URL, ct);
    }
}
# Write the Google-signed identity token to a file the CLI can read
ANTHROPIC_IDENTITY_TOKEN_FILE=$(mktemp)
curl -sS -H "Metadata-Flavor: Google" \
  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.anthropic.com&format=full" \
  > "$ANTHROPIC_IDENTITY_TOKEN_FILE"
export ANTHROPIC_IDENTITY_TOKEN_FILE

# ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID, and
# ANTHROPIC_SERVICE_ACCOUNT_ID, and ANTHROPIC_WORKSPACE_ID are read from the environment.
ant messages create \
  --model claude-opus-5-5 \
  --max-tokens 1024 \
  --message '{role: user, content: "Hello from Cloud Run"}'
use Anthropic\Client;
use Anthropic\Credentials\WorkloadIdentityCredentials;

const METADATA_URL = 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.anthropic.com&format=full';

$context = stream_context_create([
    'http' => ['header' => "Metadata-Flavor: Google\r\n"],
]);

$credentials = new WorkloadIdentityCredentials(
    identityTokenProvider: fn() => file_get_contents(METADATA_URL, false, $context),
    federationRuleId: getenv('ANTHROPIC_FEDERATION_RULE_ID'),
    organizationId: getenv('ANTHROPIC_ORGANIZATION_ID'),
    serviceAccountId: getenv('ANTHROPIC_SERVICE_ACCOUNT_ID'),
    workspaceId: getenv('ANTHROPIC_WORKSPACE_ID') ?: null,
);
$client = new Client(credentials: $credentials);

$message = $client->messages->create(
    model: 'claude-opus-5-5',
    maxTokens: 1024,
    messages: [['role' => 'user', 'content' => 'Hello from Cloud Run']],
);
$textBlock = array_find($message->content, static fn ($block): bool => $block->type === 'text');
echo $textBlock->text, PHP_EOL;
require "anthropic"
require "net/http"

METADATA_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.anthropic.com&format=full"

credentials = Anthropic::WorkloadIdentityCredentials.new(
  identity_token_provider: -> { Net::HTTP.get(URI(METADATA_URL), {"Metadata-Flavor" => "Google"}) },
  federation_rule_id: ENV.fetch("ANTHROPIC_FEDERATION_RULE_ID"),
  organization_id: ENV.fetch("ANTHROPIC_ORGANIZATION_ID"),
  service_account_id: ENV.fetch("ANTHROPIC_SERVICE_ACCOUNT_ID"),
  workspace_id: ENV["ANTHROPIC_WORKSPACE_ID"]
)
client = Anthropic::Client.new(credentials: credentials)

message = client.messages.create(
  model: "claude-opus-5-5",
  max_tokens: 1024,
  messages: [{role: "user", content: "Hello from Cloud Run"}]
)
puts message.content.find { it.type == :text }.text

Google 아이덴티티 토큰은 대략 한 시간 후에 만료돼요. SDK는 만료 전에 토큰 공급자를 다시 호출하고 재교환을 자동으로 해요. 접근 토큰의 expires_in보다 길게 실행되는 셸 스크립트는 타이머로 갱신하고 교환을 반복하세요.

설정 검증하기

워크로드 안에서 아이덴티티 토큰을 디코딩하고 클레임이 규칙과 일치하는지 확인하세요:

curl -sS -H "Metadata-Flavor: Google" \
  "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://api.anthropic.com&format=full" \
  | jq -rR 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'

isshttps://accounts.google.com이고, audhttps://api.anthropic.com이며, email이 페더레이션 규칙의 값과 일치하는지 확인하세요. 그런 다음 이전 섹션의 교환을 실행하세요. 성공한 교환은 sk-로 시작하는 access_token과 초 단위의 expires_in 값을 반환해요. 불투명한 401 authentication_error 응답(메시지 Authentication failed)으로 교환이 실패하면 인증 기록 페이지에서 거부 이유를 확인하고 실패한 교환 문제 해결을 참고하세요. 가장 흔한 Google Cloud 측 원인은 email 클레임 누락(토큰을 format=full로 요청해 포함시키기)이에요.

규칙 범위 지정하기

경고 Google sub 클레임은 서비스 계정의 불투명한 숫자 고유 ID이며 안정된 접두사가 없어요. 끝자리 *가 있는 subject_prefix는 모든 Google Cloud 프로젝트의 임의의 서비스 계정과 일치하며, 그 중 어느 것도 페더레이션 Anthropic 토큰을 얻을 수 있어요.

규칙의 match 블록을 사용 사례에 맞는 가장 좁은 범위로 잠그세요:

  • sub를 정확히 일치: claims.sub에 전체 숫자 고유 ID를 설정하고 Google 토큰에는 subject_prefix를 절대 사용하지 마세요.
  • email 클레임 고정: sub와 함께 claims.email을 추가해 안정된 ID와 읽을 수 있는 주소 둘 다 일치해야 하게 하세요.
  • audience 고정: 메타데이터 서버에서 요청하는 정확한 값으로 audience를 설정해 다른 소비자용으로 발행된 토큰이 거부되게 하세요.
  • GKE에서 프로젝트 고정: format=full 토큰의 경우 claims.google.compute_engine.project_id == "my-project" 같은 condition을 추가해 규칙을 한 프로젝트의 노드로 제한하세요.

다음 단계

  • Workload Identity Federation 페이지를 읽어 전체 리소스 모델과 SDK 자격 증명 우선순위를 확인하세요.
  • 환경마다(프로덕션, 스테이징) 별도 페더레이션 규칙을 추가해 하나를 취소해도 다른 것에 영향이 없게 하세요.

더 알아보기 (Learn more)