Microsoft Entra ID에서 WIF 사용하기

Microsoft Entra ID에서 WIF 사용하기

Azure 워크로드는 Microsoft Entra ID가 발행한 JSON Web Token(JWT)을 제시한 뒤 이를 수명이 짧은 Anthropic 접근 토큰으로 교환해 Claude API에 인증해요. 설정은 모든 Azure 플랫폼에서 같은 형태를 띄어요:

  1. 토큰 audience 등록: Microsoft Entra 테넌트에 Claude API audience를 나타내는 앱 등록을 하나 만드세요. 테넌트의 모든 워크로드가 이 audience로 Entra 토큰을 요청해요.
  2. 플랫폼용 아이덴티티 설정: VM, VM Scale Sets, App Service, Functions, Container Apps에서는 관리 아이덴티티를, AKS에서는 Entra Workload Identity를 설치하세요.
  3. Anthropic 구성: 테넌트의 Entra 발급자를 등록하고, 서비스 계정을 만들고, 토큰의 클레임과 일치하는 페더레이션 규칙을 작성하세요.
  4. 런타임에 교환: 워크로드가 POST /v1/oauth/token에서 Entra 발행 토큰을 sk-로 시작하는 Anthropic 접근 토큰으로 교환하고 Claude를 호출해요.

두 경로 모두에서 Anthropic에 제시하는 토큰은 suboid 클레임에 테넌트별 Entra 발급자와 관리 아이덴티티의 객체 ID를 나르며, 워크로드가 그 토큰을 얻는 방식만 달라요. 워크로드가 실행되는 섹션을 고르세요: VMs, VM Scale Sets, App Service, Functions, Container Apps는 관리 아이덴티티 사용, AKS는 AKS에서 Entra Workload Identity 사용.

출처: 문서

본문

사전 요구사항

  • WIF 개념에 익숙해야 해요. 서비스 계정, 페더레이션 발급자, 페더레이션 규칙.
  • 관리 아이덴티티를 할당할(AKS에서 Entra Workload Identity를 구성할) 권한이 있는 Azure 구독.
  • Microsoft Entra 테넌트에서 앱 등록과 서비스 주체 하나를 만들 권한(공유 Claude API audience). Entra는 테넌트에 존재하는 audience에 대해서만 토큰을 발행하므로, 토큰 audience 등록 단계는 어떤 토큰 요청이 성공하기 전에 필수예요.
  • Microsoft Entra 테넌트 ID. Azure 포털의 Microsoft Entra ID → Overview → Tenant ID에서 찾으세요.
  • Anthropic 조직의 Claude Console에서 서비스 계정, 페더레이션 발급자, 페더레이션 규칙을 만들 권한.

토큰 audience 등록

Microsoft Entra ID는 요청된 audience가 테넌트에 서비스 주체가 있는 앱 등록으로 존재할 때만 토큰을 발행해요. Claude API audience를 나타내는 앱 등록을 하나 만드세요. 테넌트의 모든 워크로드가 그것으로 토큰을 요청할 수 있어요. 이 등록이 없으면 토큰 요청은 "resource not found in tenant" 오류(관리 아이덴티티 엔드포인트에서는 AADSTS50001, Entra 토큰 엔드포인트에서는 AADSTS500011)로 실패해요.

# Create the app registration that represents the Claude API audience.
APP_ID=$(az ad app create --display-name claude-api-federation --query appId -o tsv)

# Request v2.0 access tokens and set the api://<APP_ID> identifier URI.
az ad app update --id "$APP_ID" \
  --identifier-uris "api://$APP_ID" \
  --set api.requestedAccessTokenVersion=2

# Create the service principal so the audience resolves in your tenant.
az ad sp create --id "$APP_ID"

참고 api://<APP_ID> 식별자 URI 형식을 사용하세요. Entra는 https:// 식별자 URI를 내 테넌트의 검증된 도메인으로 제한하므로 https://api.anthropic.com 같은 URI는 대부분의 테넌트에서 등록할 수 없어요. api://<APP_ID>는 어디서든 수락돼요. requestedAccessTokenVersion: 2로 이 audience의 토큰은 v2.0이며, 이 가이드가 가정하는 버전이에요. v1.0 토큰을 발행하는 기존 등록을 재사용한다면 토큰이 v1.0일 때를 참고하세요.

관리 아이덴티티 사용

워크로드가 VM, VM Scale Set, App Service, Functions, Container Apps에서 실행될 때 이 경로를 사용하세요. 워크로드는 할당된 관리 아이덴티티에 대한 Entra 발행 JWT를 플랫폼의 로컬 토큰 엔드포인트에서 요청한 뒤, 그 JWT를 Anthropic과 교환해요.

관리 아이덴티티 구성하기

  1. 관리 아이덴티티 연결 Azure 리소스에서 시스템 할당 또는 사용자 할당 관리 아이덴티티를 활성화하세요. Azure 포털에서 리소스를 열고 Identity로 가서 System assigned를 켜거나(사용자 할당 아이덴티티를 연결) User assigned를 켜세요. 아이덴티티가 만들어지면 Object (principal) ID를 기록하세요. 이 GUID는 발행된 토큰에서 suboid 클레임 둘 다로 나타나며, 내 Anthropic 페더레이션 규칙이 그것에 일치해요. 리소스의 Identity 페이지에서 찾을 수 있고, 사용자 할당 아이덴티티의 경우 관리 아이덴티티 리소스의 Overview 페이지에 있는 Object (principal) ID예요. (관리 아이덴티티는 Microsoft Entra ID에 앱 등록이 아니라 서비스 주체만 가져요.)

  2. 플랫폼의 토큰 엔드포인트 찾기 플랫폼은 아이덴티티가 연결되면 로컬 토큰 엔드포인트를 노출해요:

    • VMs 및 VM Scale Sets: http://169.254.169.254/metadata/identity/oauth2/token의 IMDS, Metadata: true 헤더와 api-version=2018-02-01.
    • App Service, Functions, Container Apps: IDENTITY_ENDPOINT 환경 변수의 URL, IDENTITY_HEADER 값으로 설정된 X-IDENTITY-HEADER 헤더와 api-version=2019-08-01. 이 플랫폼에서는 IMDS에 도달할 수 없어요. 리소스에 둘 이상의 사용자 할당 관리 아이덴티티가 있으면 토큰 요청에 client_id=<IDENTITY_CLIENT_ID>를 추가해 하나를 선택하세요. Azure는 항상 지정할 것을 권장해요. 지정하지 않으면 결과는 리소스에 시스템 할당 아이덴티티가 활성화되어 있는지에 따라 달라져요. 활성화되어 있으면 요청이 조용히 그 아이덴티티로 대체되고 내 페더레이션 규칙의 oid 일치를 실패시키며, 없으면 두 번째 사용자 할당 아이덴티티가 연결되는 즉시 요청이 완전히 실패해요.
  3. 샘플 토큰 디코딩 엔드포인트에서 토큰을 요청하고 페이로드를 디코딩해 내 페더레이션 규칙이 일치해야 하는 클레임을 확인하세요. (디코딩 명령은 실패한 교환 문제 해결을 참고하세요.) 관리 아이덴티티의 v2.0 토큰은 이 클레임을 나른다:

    {
      "iss": "https://login.microsoftonline.com/<TENANT_ID>/v2.0",
      "sub": "9f8e7d6c-1a2b-3c4d-5e6f-...",
      "aud": "<APP_ID>",
      "oid": "9f8e7d6c-1a2b-3c4d-5e6f-...",
      "tid": "<TENANT_ID>",
      "azp": "<IDENTITY_CLIENT_ID>",
      "ver": "2.0",
      "exp": 1775527120
    }
    
    클레임 언제 이것에 일치시키나
    oid 관리 아이덴티티의 객체 ID, sub와 동일 특정 관리 아이덴티티 하나를 승인하려 할 때. 이것이 기본이며, Anthropic 구성의 규칙이 이것에 일치한다.
    azp 호출 아이덴티티의 클라이언트 ID 하나의 앱 등록을 공유하는 모든 워크로드를 승인하려 할 때. 관리 아이덴티티의 경우 azp는 그 아이덴티티에 고유하므로 oid와 동등하다.
    aud audience 앱 등록의 클라이언트 ID(토큰 audience 등록<APP_ID> GUID) 항상. 규칙의 audience 필드는 토큰의 aud 값과 정확히 같아야 한다.
    tid 내 테넌트 ID 심층 방어를 원할 때. 발급자 URL이 이미 테넌트를 고정한다.

    디코딩된 토큰의 ver 클레임이 1.0이면 클레임 이름과 값이 달라요. 계속하기 전에 토큰이 v1.0일 때를 참고하세요.

Anthropic 구성하기

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

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

페더레이션 발급자: 마법사의 Token issuer 선택기에서 v2.0 (login.microsoftonline.com) 을 선택하세요. (선택기는 기본이 v1입니다. 그 기본은 여전히 v1.0 토큰을 발행하는 오래된 등록을 재사용하는 테넌트를 위한 것이에요.) Entra는 테넌트별 발급자 URL에 OIDC discovery 문서를 게시하므로 discovery 모드를 사용하세요. 페더레이션하는 각 Microsoft Entra 테넌트는 자체 발급자 레코드가 필요해요.

{
  "name": "azure-prod-tenant",
  "issuer_url": "https://login.microsoftonline.com/<TENANT_ID>/v2.0",
  "jwks": { "type": "discovery" },
  "max_jwt_lifetime_seconds": 86400
}

경고 관리 아이덴티티 워크로드에는 max_jwt_lifetime_seconds: 86400이 필요해요. Azure는 각 리소스의 토큰을 그 창 동안 캐시하고 조기 갱신을 강제할 방법을 제공하지 않기 때문에 iatexp 사이에 최대 24시간을 둔 관리 아이덴티티 토큰을 발행하며, 발급자의 1시간 기본값은 그 토큰들을 거부하므로 교환이 불투명한 401 authentication_error 응답(메시지 Authentication failed)으로 실패해요. Connect workload 마법사의 Microsoft Entra 타일은 max_jwt_lifetime_seconds7500으로 설정해 발급자를 만들고 생성 중에 그것을 변경할 필드를 제공하지 않으므로, 마법사를 끝낸 후 Settings → Workload identity → Issuers를 열고 발급자를 편집해 값을 86400으로 올리세요. Admin API로도 발급자를 업데이트할 수 있어요.

수락되는 수명이 길수록 유출된 Entra 토큰이 더 오래 교환 가능하다는 뜻이에요. 토큰이 유출되면 해결책은 페더레이션 규칙을 비활성화하는 것이고, 엄격한 oid 일치는 규칙 범위 지정하기에 설명된 대로 어떤 아이덴티티가 토큰을 교환할 수 있는지를 먼저 제한해요.

페더레이션 규칙: 관리 아이덴티티의 객체 ID와 테넌트 ID에 일치시키세요. 이 가이드가 구성하는 v2.0 토큰에서 audience 값은 audience 앱 등록의 클라이언트 ID(토큰 audience 등록<APP_ID> GUID)예요. 디코딩된 토큰의 정확한 aud 값을 사용하세요.

{
  "name": "azure-inference-worker",
  "issuer_id": "fdis_...",
  "match": {
    "audience": "<APP_ID>",
    "claims": {
      "oid": "9f8e7d6c-1a2b-3c4d-5e6f-...",
      "tid": "<TENANT_ID>"
    }
  },
  "target": {
    "type": "service_account",
    "service_account_id": "svac_..."
  },
  "workspace_id": "wrkspc_...",
  "oauth_scope": "workspace:developer",
  "token_lifetime_seconds": 600
}

token_lifetime_seconds는 교환이 반환하는 Anthropic 접근 토큰의 수명이지 Entra 토큰의 수명이 아니며, SDK가 갱신을 처리해 줘요.

토큰 획득과 사용

런타임에 워크로드는 Entra 토큰을 가져와 POST /v1/oauth/token에서 교환하고 반환된 베어러 토큰으로 Claude를 호출해요. 토큰 공급자 callable을 제공하면 각 Anthropic SDK가 교환·갱신 루프를 처리해요. 다음 예시에서 확인할 수 있어요. cURL 탭은 원시 흐름을 보여줘요.

샘플들은 플랫폼의 토큰 엔드포인트(VMs·VM Scale Sets의 IMDS, App Service·Functions·Container Apps의 IDENTITY_ENDPOINT 서비스)에서 관리 아이덴티티 토큰을 가져와요. api://<APP_ID> 리소스 값의 <APP_ID>토큰 audience 등록의 audience 앱 등록 클라이언트 ID로 바꾸세요.

워크로드가 이미 Azure Identity 클라이언트 라이브러리를 사용한다면, 토큰 엔드포인트를 직접 호출하는 대신 그 토큰 획득(DefaultAzureCredential + 범위 api://<APP_ID>/.default)을 아이덴티티 토큰 공급자로 전달하세요. 라이브러리는 Entra Workload Identity가 있는 AKS를 포함한 모든 Azure 플랫폼에서 올바른 엔드포인트를 선택해요.

# 1. Fetch the Entra-issued token (managed identity).
#    On a VM or VM Scale Set, use IMDS. With multiple user-assigned
#    identities, append &client_id=<IDENTITY_CLIENT_ID>.
ENTRA_TOKEN=$(curl -sS -H "Metadata: true" \
  "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=api://<APP_ID>" \
  | jq -r .access_token)

#    On App Service, Functions, or Container Apps, use the local token
#    service instead (IMDS is not reachable there):
# ENTRA_TOKEN=$(curl -sS -H "X-IDENTITY-HEADER: $IDENTITY_HEADER" \
#   "$IDENTITY_ENDPOINT?api-version=2019-08-01&resource=api://<APP_ID>" \
#   | jq -r .access_token)

#    For AKS with Entra Workload Identity, use the two-hop exchange in the
#    "Use Entra Workload Identity on AKS" section instead.

# 2. Exchange it for an Anthropic access token.
RESPONSE=$(curl -sS https://api.anthropic.com/v1/oauth/token \
  -H "content-type: application/json" \
  -d @- <<JSON
{
  "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
  "assertion": "$ENTRA_TOKEN",
  "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)

# 3. Call the Claude API with the bearer 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 from Azure"}]
  }' | jq -r '.content[] | select(.type == "text") | .text'
import os

import anthropic
import requests
from anthropic import WorkloadIdentityCredentials

# The audience app registration's identifier URI (see Register the token audience).
AUDIENCE = "api://<APP_ID>"


def fetch_entra_token() -> str:
    """Fetch a managed identity token from the platform's token endpoint."""
    # With multiple user-assigned identities, add client_id=<IDENTITY_CLIENT_ID>
    # to the request params to select one.
    if endpoint := os.environ.get("IDENTITY_ENDPOINT"):
        # App Service, Functions, Container Apps
        response = requests.get(
            endpoint,
            headers={"X-IDENTITY-HEADER": os.environ["IDENTITY_HEADER"]},
            params={"api-version": "2019-08-01", "resource": AUDIENCE},
            timeout=5,
        )
    else:
        # VM or VM Scale Set: Azure Instance Metadata Service (IMDS)
        response = requests.get(
            "http://169.254.169.254/metadata/identity/oauth2/token",
            headers={"Metadata": "true"},
            params={"api-version": "2018-02-01", "resource": AUDIENCE},
            timeout=5,
        )
    response.raise_for_status()
    return response.json()["access_token"]


client = anthropic.Anthropic(
    credentials=WorkloadIdentityCredentials(
        identity_token_provider=fetch_entra_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 Azure"}],
)
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";

// The audience app registration's identifier URI (see Register the token audience).
const AUDIENCE = "api://<APP_ID>";

async function fetchEntraToken(): Promise<string> {
  // App Service, Functions, and Container Apps inject IDENTITY_ENDPOINT;
  // VMs and VM Scale Sets use IMDS.
  // With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
  const identityEndpoint = process.env.IDENTITY_ENDPOINT;
  const url = identityEndpoint
    ? `${identityEndpoint}?api-version=2019-08-01&resource=${AUDIENCE}`
    : `http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=${AUDIENCE}`;
  const headers: Record<string, string> = identityEndpoint
    ? { "X-IDENTITY-HEADER": process.env.IDENTITY_HEADER! }
    : { Metadata: "true" };
  const response = await fetch(url, { headers });
  const body = (await response.json()) as { access_token: string };
  return body.access_token;
}

const client = new Anthropic({
  credentials: oidcFederationProvider({
    identityTokenProvider: fetchEntraToken,
    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 Azure" }]
});
for (const block of message.content) {
  if (block.type === "text") {
    console.log(block.text);
  }
}
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/option"
)

// The audience app registration's identifier URI (see Register the token audience).
const audience = "api://<APP_ID>"

// fetchEntraToken fetches a managed identity token from the platform's token
// endpoint: IMDS on VMs and VM Scale Sets, or the IDENTITY_ENDPOINT service
// on App Service, Functions, and Container Apps.
func fetchEntraToken(ctx context.Context) (string, error) {
	// With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
	tokenURL := "http://169.254.169.254/metadata/identity/oauth2/token" +
		"?api-version=2018-02-01&resource=" + audience
	header, value := "Metadata", "true"
	if endpoint := os.Getenv("IDENTITY_ENDPOINT"); endpoint != "" {
		tokenURL = endpoint + "?api-version=2019-08-01&resource=" + audience
		header, value = "X-IDENTITY-HEADER", os.Getenv("IDENTITY_HEADER")
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil)
	if err != nil {
		return "", err
	}
	req.Header.Set(header, value)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("call token endpoint: %w", err)
	}
	defer resp.Body.Close()
	var body struct {
		AccessToken string `json:"access_token"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
		return "", fmt.Errorf("decode token response: %w", err)
	}
	return body.AccessToken, nil
}

func main() {
	client := anthropic.NewClient(
		option.WithFederationTokenProvider(fetchEntraToken, 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 Azure")),
		},
	})
	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();
// The audience app registration's identifier URI (see Register the token audience).
String audience = "api://<APP_ID>";
// App Service, Functions, and Container Apps inject IDENTITY_ENDPOINT;
// VMs and VM Scale Sets use IMDS.
// With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
String identityEndpoint = System.getenv("IDENTITY_ENDPOINT");
HttpRequest tokenRequest = identityEndpoint != null
        ? HttpRequest.newBuilder(URI.create(identityEndpoint + "?api-version=2019-08-01&resource=" + audience))
                .header("X-IDENTITY-HEADER", System.getenv("IDENTITY_HEADER"))
                .build()
        : HttpRequest.newBuilder(URI.create("http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=" + audience))
                .header("Metadata", "true")
                .build();

IdentityTokenProvider fetchEntraToken = () -> {
    try {
        var response = http.send(tokenRequest, HttpResponse.BodyHandlers.ofString());
        return new ObjectMapper().readTree(response.body()).get("access_token").asText();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
};

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

var message = client.messages().create(MessageCreateParams.builder()
        .model(Model.CLAUDE_OPUS_5_5)
        .maxTokens(1024)
        .addUserMessage("Hello from Azure")
        .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 EntraTokenProvider(),
});
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 Azure" }],
});
foreach (var block in message.Content)
{
    if (block.Value is TextBlock textBlock)
    {
        Console.WriteLine(textBlock.Text);
    }
}

class EntraTokenProvider : IIdentityTokenProvider
{
    // The audience app registration's identifier URI (see Register the token audience).
    private const string Audience = "api://<APP_ID>";

    private static readonly HttpClient httpClient = new();

    public async Task<string> GetIdentityTokenAsync(CancellationToken ct = default)
    {
        // App Service, Functions, and Container Apps inject IDENTITY_ENDPOINT;
        // VMs and VM Scale Sets use IMDS.
        // With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
        var identityEndpoint = Environment.GetEnvironmentVariable("IDENTITY_ENDPOINT");
        using var request = identityEndpoint is not null
            ? new HttpRequestMessage(HttpMethod.Get,
                $"{identityEndpoint}?api-version=2019-08-01&resource={Audience}")
            {
                Headers = { { "X-IDENTITY-HEADER", Environment.GetEnvironmentVariable("IDENTITY_HEADER") } },
            }
            : new HttpRequestMessage(HttpMethod.Get,
                $"http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource={Audience}")
            {
                Headers = { { "Metadata", "true" } },
            };
        using var response = await httpClient.SendAsync(request, ct);
        response.EnsureSuccessStatusCode();
        using var json = await JsonDocument.ParseAsync(
            await response.Content.ReadAsStreamAsync(ct), default, ct);
        return json.RootElement.GetProperty("access_token").GetString()!;
    }
}
use Anthropic\Client;
use Anthropic\Credentials\WorkloadIdentityCredentials;

// The audience app registration's identifier URI (see Register the token audience).
const AUDIENCE = 'api://<APP_ID>';

function fetchEntraToken(): string
{
    // App Service, Functions, and Container Apps inject IDENTITY_ENDPOINT;
    // VMs and VM Scale Sets use IMDS.
    // With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
    $identityEndpoint = getenv('IDENTITY_ENDPOINT');
    if ($identityEndpoint !== false) {
        $url = $identityEndpoint . '?api-version=2019-08-01&resource=' . AUDIENCE;
        $header = 'X-IDENTITY-HEADER: ' . getenv('IDENTITY_HEADER');
    } else {
        $url = 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=' . AUDIENCE;
        $header = 'Metadata: true';
    }
    $context = stream_context_create([
        'http' => ['header' => $header . "\r\n"],
    ]);
    $body = json_decode(file_get_contents($url, false, $context), true);
    return $body['access_token'];
}

$credentials = new WorkloadIdentityCredentials(
    identityTokenProvider: fetchEntraToken(...),
    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 Azure']],
);
$textBlock = array_find($message->content, static fn ($block): bool => $block->type === 'text');
echo $textBlock->text, PHP_EOL;
require "anthropic"
require "json"
require "net/http"

# The audience app registration's identifier URI (see Register the token audience).
AUDIENCE = "api://<APP_ID>"

def fetch_entra_token
  # App Service, Functions, and Container Apps inject IDENTITY_ENDPOINT;
  # VMs and VM Scale Sets use IMDS.
  # With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
  if (endpoint = ENV["IDENTITY_ENDPOINT"])
    url = "#{endpoint}?api-version=2019-08-01&resource=#{AUDIENCE}"
    headers = {"X-IDENTITY-HEADER" => ENV.fetch("IDENTITY_HEADER")}
  else
    url = "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=#{AUDIENCE}"
    headers = {"Metadata" => "true"}
  end
  response = Net::HTTP.get(URI(url), headers)
  JSON.parse(response).fetch("access_token")
end

credentials = Anthropic::WorkloadIdentityCredentials.new(
  identity_token_provider: -> { fetch_entra_token },
  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 Azure"}]
)
puts message.content.find { it.type == :text }.text
# Write the Entra-issued access token to a file the CLI can read.
# Shown for a VM or VM Scale Set (IMDS). On App Service, Functions, or
# Container Apps, fetch from "$IDENTITY_ENDPOINT?api-version=2019-08-01&resource=api://<APP_ID>"
# with -H "X-IDENTITY-HEADER: $IDENTITY_HEADER" instead.
# With multiple user-assigned identities, append &client_id=<IDENTITY_CLIENT_ID>.
ANTHROPIC_IDENTITY_TOKEN_FILE=$(mktemp)
trap 'rm -f "$ANTHROPIC_IDENTITY_TOKEN_FILE"' EXIT
curl -sS -H "Metadata: true" \
  "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=api://<APP_ID>" \
  | jq -r .access_token > "$ANTHROPIC_IDENTITY_TOKEN_FILE"
export ANTHROPIC_IDENTITY_TOKEN_FILE

# ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID,
# 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 Azure"}'

설정 검증하기

Azure 리소스에서 토큰 획득과 사용에 표시된 cURL 교환을 실행하고 POST /v1/oauth/tokensk-로 시작하는 access_token과 초 단위의 expires_in 값과 함께 200을 반환하는지 확인하세요. 불투명한 401 authentication_error 응답(메시지 Authentication failed)으로 교환이 실패하면 인증 기록 페이지에서 거부 이유를 확인한 뒤 Entra 토큰을 디코딩하고(명령은 실패한 교환 문제 해결 참고) 가장 흔한 Azure 측 원인을 확인하세요:

  • 발급자 불일치: 등록된 issuer_url은 토큰의 iss 클레임과 정확히 일치해야 해요. v2.0 토큰은 https://login.microsoftonline.com/<TENANT_ID>/v2.0을 나른다. 디코딩된 ver 클레임이 1.0이면 토큰이 v1.0일 때를 참고하세요.
  • 토큰 수명: 관리 아이덴티티 토큰은 iatexp 사이에 최대 24시간을 나른다. 발급자에 여전히 마법사의 7500(또는 1시간 기본값)이 있으면 Anthropic 구성에 설명된 대로 max_jwt_lifetime_seconds86400으로 올리세요.
  • audience 불일치: 규칙의 audience는 토큰의 aud와 정확히 같아야 해요. 이 가이드가 구성하는 v2.0 토큰의 audience 앱 등록 클라이언트 ID.
  • 클레임 이름 불일치: 토큰이 나르지 않는 클레임에 일치하는 규칙은 결코 통과하지 않아요. v1.0 토큰은 azp가 아니라 appid에 클라이언트 ID를 나른다. 토큰이 v1.0일 때 참고.

AKS에서 Entra Workload Identity 사용

워크로드가 AKS 파드에서 실행될 때 이 경로를 사용하세요. Entra Workload Identity는 Kubernetes 서비스 계정을 사용자 할당 관리 아이덴티티에 페더레이션해요. Kubernetes는 서비스 계정 토큰(AKS 클러스터의 OIDC 발급자가 서명)을 AZURE_FEDERATED_TOKEN_FILE의 경로에 파드로 프로젝트해요. 그 프로젝티드 토큰은 Entra 발행 토큰이 아니므로, 이 페이지에 설명된 Entra 매개 경로를 유지하려면 워크로드가 두 홉 교환을 수행해요. 먼저 프로젝티드 토큰을 https://login.microsoftonline.com/<TENANT_ID>/oauth2/v2.0/token(페더레이션 client_credentials 부여)에서 Entra 발행 접근 토큰으로 상환하고, 그 Entra 토큰을 Anthropic SDK에 아이덴티티 토큰으로 전달해요.

AKS 파드는 Entra 교환을 건너뛰고 Kubernetes 프로젝티드 서비스 계정 토큰을 Anthropic에 직접 제시할 수 있어요. 그 경로는 Entra 테넌트 대신 AKS 클러스터의 OIDC 발급자를 Anthropic에 등록해요. 그 흐름은 Kubernetes에서 WIF 사용하기를 참고하세요.

Entra Workload Identity 구성하기

  1. 클러스터에서 OIDC 발급자와 워크로드 아이덴티티 활성화 워크로드 아이덴티티를 활성화하면 azure-workload-identity 변형 웹훅이 설치돼요. 비-AKS 클러스터에서만 수동으로 배포하세요. 나중 단계에서 만드는 페더레이션 자격 증명을 위해 클러스터의 OIDC 발급자 URL을 기록하세요.

    az aks update \
      --resource-group <RESOURCE_GROUP> \
      --name <CLUSTER_NAME> \
      --enable-oidc-issuer \
      --enable-workload-identity
    
    AKS_OIDC_ISSUER=$(az aks show \
      --resource-group <RESOURCE_GROUP> \
      --name <CLUSTER_NAME> \
      --query oidcIssuerProfile.issuerUrl -o tsv)
    
  2. 사용자 할당 관리 아이덴티티 만들기 아이덴티티에서 두 값을 기록하세요. Client ID는 서비스 계정 어노테이션에 들어가고(파드에 AZURE_CLIENT_ID로 주입), Object (principal) ID는 내 Anthropic 페더레이션 규칙이 일치하는 oid 클레임으로 나타나요.

    az identity create \
      --resource-group <RESOURCE_GROUP> \
      --name claude-inference-identity \
      --location <LOCATION>
    
    # Goes in the service account annotation; injected into the pod as AZURE_CLIENT_ID.
    IDENTITY_CLIENT_ID=$(az identity show \
      --resource-group <RESOURCE_GROUP> \
      --name claude-inference-identity \
      --query clientId -o tsv)
    
    # Appears as the oid claim that your federation rule matches.
    IDENTITY_OBJECT_ID=$(az identity show \
      --resource-group <RESOURCE_GROUP> \
      --name claude-inference-identity \
      --query principalId -o tsv)
    
  3. 어노테이션된 Kubernetes 서비스 계정 만들기 azure-workload-identity 웹훅은 azure.workload.identity/client-id 어노테이션을 읽어 AZURE_CLIENT_ID를 파드에 주입하고, 샘플들은 토큰 획득과 사용에서 환경에서 읽어요.

    apiVersion: v1
    kind: ServiceAccount
    metadata:
      name: claude-inference
      namespace: inference
      annotations:
        azure.workload.identity/client-id: <IDENTITY_CLIENT_ID>
    
  4. 관리 아이덴티티에 페더레이션 자격 증명 만들기 페더레이션 자격 증명은 그 특정 서비스 계정에 대해 클러스터의 OIDC 발급자를 신뢰해요. --audience api://AzureADTokenExchange 값은 들어오는 Kubernetes 서비스 계정 토큰에 대한 Entra의 고정 audience이며, 이전에 등록한 Claude API audience와 무관해요.

    az identity federated-credential create \
      --resource-group <RESOURCE_GROUP> \
      --identity-name claude-inference-identity \
      --name claude-inference-aks \
      --issuer "$AKS_OIDC_ISSUER" \
      --subject system:serviceaccount:inference:claude-inference \
      --audience api://AzureADTokenExchange
    
  5. 파드에 레이블을 붙이고 서비스 계정 설정 파드는 azure.workload.identity/use: "true" 레이블을 나르고 어노테이션된 서비스 계정으로 실행해야 해요. 그러면 웹훅이 AZURE_FEDERATED_TOKEN_FILE, AZURE_CLIENT_ID, AZURE_TENANT_ID를 파드에 주입해요. AZURE_FEDERATED_TOKEN_FILE의 파일에는 AKS 클러스터의 OIDC 발급자가 서명한 Kubernetes 프로젝티드 서비스 계정 토큰이 들어 있어요.

    apiVersion: v1
    kind: Pod
    metadata:
      name: inference-worker
      namespace: inference
      labels:
        azure.workload.identity/use: "true"
    spec:
      serviceAccountName: claude-inference
      containers:
        - name: app
          image: your-registry/inference-worker:latest
    
  6. 샘플 토큰 디코딩 내 Anthropic 페더레이션 규칙이 보는 토큰은 프로젝티드 파일이 아니라 client_credentials 교환이 반환한 Entra 발행 토큰이에요. 레이블이 붙은 파드 안에서 토큰 획득과 사용의 cURL 샘플 1단계를 실행하고 결과를 디코딩하세요. 관리 아이덴티티 경로와 같은 클레임 형태를 나른다:

    {
      "iss": "https://login.microsoftonline.com/<TENANT_ID>/v2.0",
      "sub": "9f8e7d6c-1a2b-3c4d-5e6f-...",
      "aud": "<APP_ID>",
      "oid": "9f8e7d6c-1a2b-3c4d-5e6f-...",
      "tid": "<TENANT_ID>",
      "azp": "<IDENTITY_CLIENT_ID>",
      "ver": "2.0",
      "exp": 1775527120
    }
    

    suboid는 관리 아이덴티티의 객체 ID, aud는 audience 앱 등록의 클라이언트 ID, azp는 관리 아이덴티티의 클라이언트 ID(AZURE_CLIENT_ID 값)예요. 수명은 관리 아이덴티티 경로와 달라요. client_credentials 토큰은 24시간이 아니라 iatexp 사이에 기본으로 무작위 60~90분 창을 가진다.

Anthropic 구성하기

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

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

페더레이션 발급자: 마법사의 Token issuer 선택기에서 v2.0 (login.microsoftonline.com) 을 선택하세요. (선택기는 기본이 v1입니다. 그 기본은 여전히 v1.0 토큰을 발행하는 오래된 등록을 재사용하는 테넌트를 위한 것이에요.) Entra는 테넌트별 발급자 URL에 OIDC discovery 문서를 게시하므로 discovery 모드를 사용하세요. 페더레이션하는 각 Microsoft Entra 테넌트는 자체 발급자 레코드가 필요해요.

{
  "name": "azure-prod-tenant",
  "issuer_url": "https://login.microsoftonline.com/<TENANT_ID>/v2.0",
  "jwks": { "type": "discovery" },
  "max_jwt_lifetime_seconds": 7500
}

경고 Connect workload 마법사의 Microsoft Entra 타일은 max_jwt_lifetime_seconds7500(2시간 조금 넘게)으로 설정해 발급자를 만들어, client_credentials 토큰의 기본 60~90분 수명을 포괄해요. 테넌트 토큰 수명 정책이나 Continuous Access Evaluation(CAE)이 그 수명을 연장할 수 있어요. 디코딩된 토큰의 exp 빼기 iat가 7500초를 초과하면 Settings → Workload identity → Issuers에서 발급자를 편집해 max_jwt_lifetime_seconds를 그 값으로 올리세요. 그렇지 않으면 교환이 불투명한 401 authentication_error 응답(메시지 Authentication failed)으로 실패해요. 테넌트가 관리 아이덴티티 사용의 관리 아이덴티티 워크로드도 실행한다면, 두 경로를 모두 포괄하는 그 섹션의 86400 값을 사용하세요.

수락되는 수명이 길수록 유출된 Entra 토큰이 더 오래 교환 가능하다는 뜻이에요. 토큰이 유출되면 해결책은 페더레이션 규칙을 비활성화하는 것이고, 엄격한 oid 일치는 규칙 범위 지정하기에 설명된 대로 어떤 아이덴티티가 토큰을 교환할 수 있는지를 먼저 제한해요.

페더레이션 규칙: 관리 아이덴티티의 객체 ID와 테넌트 ID에 일치시키세요. 이 가이드가 구성하는 v2.0 토큰에서 audience 값은 audience 앱 등록의 클라이언트 ID(토큰 audience 등록<APP_ID> GUID)예요. 디코딩된 토큰의 정확한 aud 값을 사용하세요.

{
  "name": "azure-inference-worker",
  "issuer_id": "fdis_...",
  "match": {
    "audience": "<APP_ID>",
    "claims": {
      "oid": "9f8e7d6c-1a2b-3c4d-5e6f-...",
      "tid": "<TENANT_ID>"
    }
  },
  "target": {
    "type": "service_account",
    "service_account_id": "svac_..."
  },
  "workspace_id": "wrkspc_...",
  "oauth_scope": "workspace:developer",
  "token_lifetime_seconds": 600
}

token_lifetime_seconds는 교환이 반환하는 Anthropic 접근 토큰의 수명이지 Entra 토큰의 수명이 아니며, SDK가 갱신을 처리해 줘요.

토큰 획득과 사용

런타임에 파드는 두 홉 교환을 수행해요. Kubernetes 프로젝티드 토큰(AZURE_FEDERATED_TOKEN_FILE의 파일)을 페더레이션 client_credentials assertion으로 Entra의 토큰 엔드포인트에 보내고, 결과 Entra 접근 토큰을 POST /v1/oauth/token에서 교환해요. Entra 가져오기를 토큰 공급자 callable로 제공하면 각 Anthropic SDK가 두 번째 교환과 갱신 루프를 처리해요. 다음 예시에서 확인할 수 있어요. cURL 탭은 원시 흐름을 보여줘요.

샘플에 두 개의 서로 다른 클라이언트 ID가 나타나요. <APP_ID>토큰 audience 등록의 audience 앱 등록 클라이언트 ID이고, 범위 api://<APP_ID>/.default는 Entra에 그 audience를 향한 토큰을 요청해요. $AZURE_CLIENT_ID는 웹훅이 주입한 관리 아이덴티티의 클라이언트 ID이며 호출자를 식별해요. 하나를 다른 것으로 대체하지 마세요.

워크로드가 이미 Azure Identity 클라이언트 라이브러리를 사용한다면, 두 홉 교환을 스스로 수행하는 대신 그 토큰 획득(DefaultAzureCredential + 범위 api://<APP_ID>/.default)을 아이덴티티 토큰 공급자로 전달하세요. 라이브러리는 같은 AZURE_FEDERATED_TOKEN_FILE, AZURE_CLIENT_ID, AZURE_TENANT_ID 환경 변수를 읽고 Entra 교환을 처리해요.

# 1. Exchange the Kubernetes-projected token (at $AZURE_FEDERATED_TOKEN_FILE)
#    for an Entra-issued JWT.
ENTRA_JWT=$(curl -sS "https://login.microsoftonline.com/$AZURE_TENANT_ID/oauth2/v2.0/token" \
  -d grant_type=client_credentials \
  -d "client_id=$AZURE_CLIENT_ID" \
  --data-urlencode "scope=api://<APP_ID>/.default" \
  -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
  --data-urlencode "client_assertion@$AZURE_FEDERATED_TOKEN_FILE" \
  | jq -r .access_token)

# 2. Exchange the Entra JWT for an Anthropic access token.
ACCESS_TOKEN=$(curl -sS https://api.anthropic.com/v1/oauth/token \
  -H "content-type: application/json" \
  -d @- <<JSON | jq -r .access_token
{
  "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
  "assertion": "$ENTRA_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
)

# 3. 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 Azure"}]
  }' | jq -r '.content[] | select(.type == "text") | .text'
import os
from pathlib import Path

import anthropic
import requests
from anthropic import WorkloadIdentityCredentials


def fetch_entra_token_via_federation() -> str:
    federated_token = Path(os.environ["AZURE_FEDERATED_TOKEN_FILE"]).read_text()
    response = requests.post(
        f"https://login.microsoftonline.com/{os.environ['AZURE_TENANT_ID']}/oauth2/v2.0/token",
        data={
            "client_id": os.environ["AZURE_CLIENT_ID"],
            "grant_type": "client_credentials",
            "scope": "api://<APP_ID>/.default",
            "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
            "client_assertion": federated_token,
        },
        timeout=5,
    )
    response.raise_for_status()
    return response.json()["access_token"]


client = anthropic.Anthropic(
    credentials=WorkloadIdentityCredentials(
        identity_token_provider=fetch_entra_token_via_federation,
        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 Azure"}],
)
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";
import { readFile } from "node:fs/promises";

async function fetchEntraTokenViaFederation(): Promise<string> {
  const federatedToken = await readFile(process.env.AZURE_FEDERATED_TOKEN_FILE!, "utf8");
  const response = await fetch(
    `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/oauth2/v2.0/token`,
    {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        client_id: process.env.AZURE_CLIENT_ID!,
        grant_type: "client_credentials",
        scope: "api://<APP_ID>/.default",
        client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
        client_assertion: federatedToken
      })
    }
  );
  const body = (await response.json()) as { access_token: string };
  return body.access_token;
}

const client = new Anthropic({
  credentials: oidcFederationProvider({
    identityTokenProvider: fetchEntraTokenViaFederation,
    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 Azure" }]
});
for (const block of message.content) {
  if (block.type === "text") {
    console.log(block.text);
  }
}
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"strings"

	"github.com/anthropics/anthropic-sdk-go"
	"github.com/anthropics/anthropic-sdk-go/option"
)

func fetchEntraTokenViaFederation(ctx context.Context) (string, error) {
	federatedToken, err := os.ReadFile(os.Getenv("AZURE_FEDERATED_TOKEN_FILE"))
	if err != nil {
		return "", err
	}
	form := url.Values{
		"client_id":             {os.Getenv("AZURE_CLIENT_ID")},
		"grant_type":            {"client_credentials"},
		"scope":                 {"api://<APP_ID>/.default"},
		"client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
		"client_assertion":      {strings.TrimSpace(string(federatedToken))},
	}
	tokenURL := "https://login.microsoftonline.com/" + os.Getenv("AZURE_TENANT_ID") + "/oauth2/v2.0/token"
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
	if err != nil {
		return "", err
	}
	req.Header.Set("content-type", "application/x-www-form-urlencoded")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	var body struct {
		AccessToken string `json:"access_token"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
		return "", err
	}
	return body.AccessToken, nil
}

func main() {
	client := anthropic.NewClient(
		option.WithFederationTokenProvider(option.IdentityTokenFunc(fetchEntraTokenViaFederation), 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 Azure")),
		},
	})
	if err != nil {
		panic(err)
	}
	for _, block := range message.Content {
		if textBlock, ok := block.AsAny().(anthropic.TextBlock); ok {
			fmt.Println(textBlock.Text)
			break
		}
	}
}
IdentityTokenProvider fetchEntraTokenViaFederation = () -> {
    try {
        var form = Map.of(
                        "client_id", System.getenv("AZURE_CLIENT_ID"),
                        "grant_type", "client_credentials",
                        "scope", "api://<APP_ID>/.default",
                        "client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
                        "client_assertion", Files.readString(Path.of(System.getenv("AZURE_FEDERATED_TOKEN_FILE"))))
                .entrySet().stream()
                .map(entry -> entry.getKey() + "=" + URLEncoder.encode(entry.getValue(), UTF_8))
                .collect(Collectors.joining("&"));
        var request = HttpRequest.newBuilder(URI.create(
                        "https://login.microsoftonline.com/" + System.getenv("AZURE_TENANT_ID") + "/oauth2/v2.0/token"))
                .header("content-type", "application/x-www-form-urlencoded")
                .POST(HttpRequest.BodyPublishers.ofString(form))
                .build();
        var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
        return new ObjectMapper().readTree(response.body()).get("access_token").asText();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
};

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

var message = client.messages().create(MessageCreateParams.builder()
        .model(Model.CLAUDE_OPUS_5_5)
        .maxTokens(1024)
        .addUserMessage("Hello from Azure")
        .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 EntraFederationTokenProvider(),
});
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 Azure" }],
});
foreach (var block in message.Content)
{
    if (block.Value is TextBlock textBlock)
    {
        Console.WriteLine(textBlock.Text);
    }
}

class EntraFederationTokenProvider : IIdentityTokenProvider
{
    private static readonly HttpClient Http = new();

    public async Task<string> GetIdentityTokenAsync(CancellationToken ct = default)
    {
        var federatedToken = await File.ReadAllTextAsync(
            Environment.GetEnvironmentVariable("AZURE_FEDERATED_TOKEN_FILE")!, ct);
        var tenantId = Environment.GetEnvironmentVariable("AZURE_TENANT_ID");
        var form = new FormUrlEncodedContent(new Dictionary<string, string>
        {
            ["client_id"] = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID")!,
            ["grant_type"] = "client_credentials",
            ["scope"] = "api://<APP_ID>/.default",
            ["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
            ["client_assertion"] = federatedToken,
        });
        var response = await Http.PostAsync(
            $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token", form, ct);
        response.EnsureSuccessStatusCode();
        using var json = await JsonDocument.ParseAsync(
            await response.Content.ReadAsStreamAsync(ct), default, ct);
        return json.RootElement.GetProperty("access_token").GetString()!;
    }
}
use Anthropic\Client;
use Anthropic\Credentials\WorkloadIdentityCredentials;

function fetchEntraTokenViaFederation(): string
{
    $ch = curl_init('https://login.microsoftonline.com/' . getenv('AZURE_TENANT_ID') . '/oauth2/v2.0/token');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POSTFIELDS => http_build_query([
            'client_id' => getenv('AZURE_CLIENT_ID'),
            'grant_type' => 'client_credentials',
            'scope' => 'api://<APP_ID>/.default',
            'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
            'client_assertion' => file_get_contents(getenv('AZURE_FEDERATED_TOKEN_FILE')),
        ]),
    ]);
    $body = json_decode(curl_exec($ch), true);
    curl_close($ch);
    return $body['access_token'];
}

$client = new Client(
    credentials: new WorkloadIdentityCredentials(
        identityTokenProvider: fetchEntraTokenViaFederation(...),
        federationRuleId: getenv('ANTHROPIC_FEDERATION_RULE_ID'),
        organizationId: getenv('ANTHROPIC_ORGANIZATION_ID'),
        serviceAccountId: getenv('ANTHROPIC_SERVICE_ACCOUNT_ID'),
        workspaceId: getenv('ANTHROPIC_WORKSPACE_ID') ?: null,
    ),
);

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

def fetch_entra_token_via_federation
  tenant_id = ENV.fetch("AZURE_TENANT_ID")
  federated_token = File.read(ENV.fetch("AZURE_FEDERATED_TOKEN_FILE"))
  response = Net::HTTP.post_form(
    URI("https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/token"),
    "client_id" => ENV.fetch("AZURE_CLIENT_ID"),
    "grant_type" => "client_credentials",
    "scope" => "api://<APP_ID>/.default",
    "client_assertion_type" => "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    "client_assertion" => federated_token
  )
  JSON.parse(response.body).fetch("access_token")
end

client = Anthropic::Client.new(
  credentials: Anthropic::WorkloadIdentityCredentials.new(
    identity_token_provider: -> { fetch_entra_token_via_federation },
    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"]
  )
)

message = client.messages.create(
  model: "claude-opus-5-5",
  max_tokens: 1024,
  messages: [{role: "user", content: "Hello from Azure"}]
)
puts message.content.find { it.type == :text }.text
# 1. Exchange the Kubernetes-projected token for an Entra-issued access
# token and write it to a temp file the CLI can read.
ANTHROPIC_IDENTITY_TOKEN_FILE=$(mktemp)
trap 'rm -f "$ANTHROPIC_IDENTITY_TOKEN_FILE"' EXIT
curl -sS "https://login.microsoftonline.com/$AZURE_TENANT_ID/oauth2/v2.0/token" \
  -d client_id="$AZURE_CLIENT_ID" \
  -d grant_type=client_credentials \
  --data-urlencode "scope=api://<APP_ID>/.default" \
  -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
  --data-urlencode client_assertion@"$AZURE_FEDERATED_TOKEN_FILE" \
  | jq -r .access_token > "$ANTHROPIC_IDENTITY_TOKEN_FILE"
export ANTHROPIC_IDENTITY_TOKEN_FILE

# 2. Call the Claude API. ANTHROPIC_FEDERATION_RULE_ID,
# ANTHROPIC_ORGANIZATION_ID, 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 Azure"}'

설정 검증하기

레이블이 붙은 파드 안에서 토큰 획득과 사용에 표시된 cURL 교환을 실행하고 POST /v1/oauth/tokensk-로 시작하는 access_token과 초 단위의 expires_in 값과 함께 200을 반환하는지 확인하세요. 불투명한 401 authentication_error 응답(메시지 Authentication failed)으로 교환이 실패하면 인증 기록 페이지에서 거부 이유를 확인한 뒤 1단계의 Entra 발행 토큰을 디코딩하고(명령은 실패한 교환 문제 해결 참고) 가장 흔한 Azure 측 원인을 확인하세요:

  • 발급자 불일치: 등록된 issuer_url은 토큰의 iss 클레임과 정확히 일치해야 해요. v2.0 토큰은 https://login.microsoftonline.com/<TENANT_ID>/v2.0을 나른다. 디코딩된 ver 클레임이 1.0이면 토큰이 v1.0일 때를 참고하세요.
  • 토큰 수명: 테넌트 토큰 수명 정책이나 CAE가 client_credentials 토큰을 7500초 넘게 연장하면 Anthropic 구성에 설명된 대로 발급자의 max_jwt_lifetime_seconds를 올리세요.
  • audience 불일치: 규칙의 audience는 토큰의 aud와 정확히 같아야 해요. 이 가이드가 구성하는 v2.0 토큰의 audience 앱 등록 클라이언트 ID.
  • 클레임 이름 불일치: 토큰이 나르지 않는 클레임에 일치하는 규칙은 결코 통과하지 않아요. v1.0 토큰은 azp가 아니라 appid에 클라이언트 ID를 나른다. 토큰이 v1.0일 때 참고.

토큰이 v1.0일 때

이 가이드는 audience 앱 등록을 api.requestedAccessTokenVersion: 2로 구성하므로, 표시하는 모든 토큰은 v2.0이에요. requestedAccessTokenVersion을 설정하지 않은 기존 등록을 재사용하면 Entra는 대신 v1.0 토큰을 발행해요. 샘플 토큰을 디코딩해 ver 클레임을 확인하세요. 1.0이라면 네 가지가 달라져요:

  • 발급자: iss 클레임은 https://login.microsoftonline.com/<TENANT_ID>/v2.0 대신 https://sts.windows.net/<TENANT_ID>/이에요. 발급자 URL을 토큰의 iss 클레임이 나르는 대로 정확히 등록하세요. 두 URL은 같은 JWKS를 공유하므로 어느 쪽이든 discovery 모드가 작동해요.
  • 마법사 선택기: Connect workload 마법사의 Token issuer 선택기에서 v2.0 (login.microsoftonline.com) 대신 v1 (sts.windows.net) 을 선택하세요.
  • audience: aud 클레임은 등록의 클라이언트 ID가 아니라 resource로 전달한 식별자 URI(예: api://<APP_ID>)예요. 페더레이션 규칙의 audience를 디코딩된 토큰의 정확한 aud 값으로 설정하세요.
  • 클라이언트 ID 클레임: 호출 아이덴티티의 클라이언트 ID는 azp가 아니라 appid에 나타나요. 두 클레임은 같은 토큰에 결코 나타나지 않으므로, azp에 일치하는 규칙은 v1.0 토큰에 대해 결코 통과하지 않아요.

oid, sub, tid 클레임은 두 버전에서 같은 값을 나르므로, 이 가이드의 나머지는 변경 없이 적용돼요.

규칙 범위 지정하기

페더레이션 규칙은 claims 맵에 더해(또는 대신) subject_prefix로 토큰의 subject에 일치할 수 있어요. 필드가 어떻게 결합하는지는 규칙 일치 시맨틱을 참고하세요. Entra의 sub 값은 이 아이덴티티들에 대해 고정 길이 정규 GUID이므로, 전체 36자 객체 ID를 포함하는 subject_prefix는 그 subject에만 일치해요. 이것은 subject_prefix의 일반 성질이 아니라 Entra subject 형식의 성질이에요.

경고 테넌트의 모든 아이덴티티가 등록된 audience로 토큰을 요청할 수 있으므로, audiencetid만으로는 특정 워크로드를 식별하지 못해요. oid(또는 azp/appid) 일치를 생략하거나, 와일드카드나 부분 GUID subject_prefix를 사용하는 규칙은 테넌트의 모든 관리 아이덴티티와 서비스 주체를 승인해요.

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

  • oid를 정확한 값으로 일치: claims.oid를 관리 아이덴티티의 전체 객체 ID로 설정하세요. 그 전체 객체 ID로 설정된 subject_prefix는 동등해요(콘솔 마법사가 둘 다 설정합니다). 의도보다 더 많은 아이덴티티에 일치하는 와일드카드나 부분 GUID subject_prefix는 절대 사용하지 마세요.
  • tid를 심층 방어로 고정: 발급자 URL이 이미 테넌트를 고정하지만, 발급자 레코드가 나중에 편집되면 claims.tid를 추가하는 것이 구성 드리프트를 막아줘요.
  • audience 고정: 디코딩된 토큰의 정확한 aud 값으로 audience를 설정해 다른 애플리케이션용으로 발행된 토큰이 거부되게 하세요.
  • 관리 아이덴티티마다 별도 규칙: 여러 개를 승인하는 규칙 하나보다 아이덴티티마다 규칙 하나를 만들어, 다른 것에 영향 없이 단일 워크로드의 접근을 취소할 수 있게 하세요.

다음 단계

더 알아보기 (Learn more)