在 Google Cloud 中使用 WIF

将 Google Cloud 工作负载(Cloud Run、Cloud Functions、App Engine、GCE、GKE)联合到 Claude API,使用 Google 签名的身份令牌代替静态 API 密钥。


任何可以访问实例元数据服务器的 Google Cloud 计算环境(Cloud Run、Cloud Functions、App Engine、Compute Engine (GCE) 以及启用了 Workload Identity 的 GKE)都可以为其附加的服务账号请求一个 Google 签名的身份令牌。令牌的签发方是 https://accounts.google.com,Anthropic 可以通过标准 OIDC 发现直接验证它,无需额外的 Google Cloud 配置。

本指南展示如何向 Anthropic 注册 Google 签发方、将 Google 服务账号绑定到 Anthropic 服务账号,以及让工作负载将其身份令牌交换为短期的 Claude API 访问令牌。

前置条件

  • 熟悉 WIF 概念:服务账号、联合签发方和联合规则。
  • 一个在 Cloud Run、Cloud Functions、App Engine、Compute Engine 或 GKE 上运行工作负载的 Google Cloud 项目。
  • 附加到该工作负载的用户管理的 Google 服务账号(不是 Compute Engine 默认服务账号)。
  • 拥有在 Claude Console 中为你的 Anthropic 组织创建服务账号、联合签发方和联合规则的权限。

配置 Google Cloud

Google 会自动向任何附加了服务账号的工作负载签发身份令牌。在 Google 侧,除了附加正确的服务账号外,无需启用任何功能,但标准计算和 GKE 之间的步骤略有不同。

将一个专用的服务账号附加到你的服务或实例:

gcloud run deploy my-service \
  --service-account inference-worker@my-project.iam.gserviceaccount.com

在工作负载内部,元数据服务器按需返回签名的身份令牌。使用你打算在 Anthropic 侧注册的 audience 请求它,并包含 format=full 以使响应携带 email 声明:

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": "inference-worker@my-project.iam.gserviceaccount.com",
  "email_verified": true,
  "exp": 1775527120
}

sub 声明是 Google 服务账号的不透明数字唯一 ID。email 声明是人类可读的服务账号地址。在联合规则中同时匹配 subemail

在集群上启用 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: inference-worker@my-project.iam.gserviceaccount.com

建立此绑定后,GKE 元数据服务器返回的 Google 签名令牌与 Cloud Run 和 GCE 情况完全相同:相同的 https://accounts.google.com 签发方、相同的 email 声明、相同的获取 URL。按照下一节的方式配置 Anthropic。

GKE 的 format=full 令牌还额外包含 google.compute_engine.project_idgoogle.compute_engine.zonegoogle.compute_engine.instance_name 声明,你可以在联合规则的 condition 匹配器中引用它们(如 CEL 表达式 claims.google.compute_engine.project_id == "my-project"),以将访问范围限定到特定集群或节点池。

Note

如果你不想将 Kubernetes 服务账号绑定到 Google 服务账号,GKE Pod 可以改用集群自身的 OIDC 签发方(https://container.googleapis.com/v1/projects/PROJECT/locations/REGION/clusters/CLUSTER)配合投影的 serviceAccountToken 卷。该方式使用每个集群的签发方而非 accounts.google.com。有关该模式,请参阅在 Kubernetes 中使用 WIF

配置 Anthropic

按照设置指南在 Claude Console 中注册联合签发方、创建 Anthropic 服务账号并创建联合规则。使用以下 Google Cloud 特定的值。

联合签发方: Google 公开发布了其 OIDC 发现文档,因此使用发现模式。此单个签发方涵盖所有 Google Cloud 表面(Cloud Run、GCE、Cloud Functions、App Engine 和启用了 Workload Identity 的 GKE)。使用规则而非签发方来区分工作负载。

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

联合规则: 同时匹配 subemail 声明。email 是可读的服务账号地址;sub 是服务账号的数字唯一 ID,Google 永远不会重用它,因此固定它可以保护规则在服务账号被删除且后来创建了同名新账号时不受影响。使用 gcloud iam service-accounts describe SA_EMAIL --format='value(uniqueId)' 查找唯一 ID。

{
  "name": "gcp-inference-worker",
  "issuer_id": "fdis_...",
  "match": {
    "audience": "https://api.anthropic.com",
    "claims": {
      "sub": "104892101234567890123",
      "email": "inference-worker@my-project.iam.gserviceaccount.com"
    }
  },
  "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 处交换它,然后使用返回的 Bearer 令牌调用 Claude API。当你提供一个从元数据服务器返回新鲜身份令牌的令牌提供者可调用对象时,每个 Anthropic SDK 都会为你处理交换和刷新循环,如下例所示。

# 从元数据服务器获取 Google 签名的身份令牌
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")

# 将其交换为 Anthropic 访问令牌
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)

# 调用 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-sonnet-4-6",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Hello from Cloud Run"}]
  }' | jq -r '.content[0].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-sonnet-4-6",
    max_tokens=1024,
    messages=[{"role": "user", "content": "Hello from Cloud Run"}],
)
print(message.content[0].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-sonnet-4-6",
  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);
  }
}
package main

import (
	"context"
	"fmt"
	"os"

	"cloud.google.com/go/auth/credentials/idtoken"
	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/option"
)

func main() {
	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.ModelClaudeSonnet4_6,
		MaxTokens: 1024,
		Messages: []anthropic.MessageParam{
			anthropic.NewUserMessage(anthropic.NewTextBlock("Hello from Cloud Run")),
		},
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(message.Content[0].Text)
}
import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.credentials.IdentityTokenProvider;
import com.anthropic.models.messages.MessageCreateParams;
import com.anthropic.models.messages.Model;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

void main() {
    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_SONNET_4_6)
            .maxTokens(1024)
            .addUserMessage("Hello from Cloud Run")
            .build());

    IO.println(message.content());
}
using Anthropic.Models.Messages;
using Anthropic.Oidc;

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 AnthropicOidcClient(credentials);

var message = await client.Messages.Create(new()
{
    Model = Model.ClaudeSonnet4_6,
    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);
    }
}
# 将 Google 签名的身份令牌写入 CLI 可读取的文件
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 和
# ANTHROPIC_SERVICE_ACCOUNT_ID 以及 ANTHROPIC_WORKSPACE_ID 从环境变量中读取。
ant messages create \
  --model claude-sonnet-4-6 \
  --max-tokens 1024 \
  --message '{role: user, content: "Hello from Cloud Run"}'
<?php
require 'vendor/autoload.php';

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-sonnet-4-6',
    maxTokens: 1024,
    messages: [['role' => 'user', 'content' => 'Hello from Cloud Run']],
);
echo $message->content[0]->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-sonnet-4-6",
  max_tokens: 1024,
  messages: [{role: "user", content: "Hello from Cloud Run"}]
)
puts message.content.first.text

Google 身份令牌大约在一小时后过期。SDK 会在过期前自动重新调用令牌提供者并重新交换。对于运行时间超过访问令牌 expires_in 的 Shell 脚本,请设置定时器刷新并重复交换。

验证设置

在工作负载内部,解码身份令牌并确认声明与你的规则匹配:

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.comaud 是否为 https://api.anthropic.com,以及 email 是否与你联合规则中的值匹配。然后运行上一节的交换。成功交换会返回一个以 sk-ant-oat01- 开头的 access_token 和一个以秒为单位的 expires_in 值。如果出现 400 invalid_grant,请参阅排查交换失败;最常见的 Google Cloud 侧原因是缺少 email 声明(使用 format=full 请求令牌以包含它)。

限定规则范围

Warning

Google 的 sub 声明是服务账号的不透明数字唯一 ID,没有稳定的前缀。带有末尾 *subject_prefix 可以匹配任意 Google Cloud 项目中的任意服务账号,它们中的任何一个都可以获取联合 Anthropic 令牌。

将规则的 match 块锁定到适合你用例的最窄范围:

  • 精确匹配 subclaims.sub 中设置完整的数字唯一 ID,永远不要对 Google 令牌使用 subject_prefix
  • 固定 email 声明:sub 旁边添加 claims.email,这样稳定 ID 和可读地址都必须匹配。
  • 固定受众:audience 设置为你从元数据服务器请求的精确值,这样为其他使用者铸造的令牌会被拒绝。
  • 在 GKE 上固定项目: 对于 format=full 令牌,添加 condition(如 claims.google.compute_engine.project_id == "my-project")以将规则限制在一个项目的节点上。

后续步骤

  • 阅读 Workload Identity Federation 页面了解完整的资源模型和 SDK 凭证优先级。
  • 为每个环境(生产、预发布)添加单独的联合规则,这样你可以撤销一个而不影响其他。