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 클레임은 사람이 읽을 수 있는 서비스 계정 주소예요. 페더레이션 규칙에서 sub와 email 둘 다 일치시키세요.
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" }
}
페더레이션 규칙: sub와 email 클레임 둘 다 일치시키세요. 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'
iss가 https://accounts.google.com이고, aud가 https://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 자격 증명 우선순위를 확인하세요.
- 환경마다(프로덕션, 스테이징) 별도 페더레이션 규칙을 추가해 하나를 취소해도 다른 것에 영향이 없게 하세요.