Repository Public
1# Configuring workload identity federation for Kubernetes23> For the complete documentation index, see [llms.txt](/llms.txt). Markdown versions of documentation pages are available by appending `.md` to the page URL.45Use Kubernetes as a Workload Identity Provider by exchanging a projected Kubernetes service account token for a short-lived OpenAI access token.67## Setting up Kubernetes89This guide assumes Kubernetes service account token projection is enabled, which is available by default in modern Kubernetes releases. OpenAI workload identity federation requires OIDC-compatible projected service account tokens. Legacy Kubernetes service account tokens stored in Secrets are not supported.1011Use a Kubernetes `ServiceAccount` for the workload that needs to call the OpenAI API. If you do not already have one, create it:1213```bash14kubectl create serviceaccount openai-wif --namespace default15```1617Get the OIDC issuer for your Kubernetes cluster:1819```bash20kubectl get --raw /.well-known/openid-configuration | jq -r .issuer21```2223Even if you upload the JWKS and OpenAI does not perform JWKS discovery against the OIDC issuer, this issuer must match the issuer configured in the Workload Identity Provider.2425Get the cluster JWKS and save the returned key set. You will need it when configuring the Workload Identity Provider:2627```bash28kubectl get --raw /openid/v1/jwks29```3031Configure the projected service account token with the audience OpenAI expects and an expiration suitable for your workload. OpenAI validates the token's issuer, signature, audience, and expiration. In this example, the token file is mounted at `/var/run/secrets/tokens/token`, uses the audience `https://api.openai.com/v1`, and expires after 3600 seconds. You may use a different audience if the projected token audience and OpenAI Workload Identity Provider audience match:3233```yaml34apiVersion: v135kind: Pod36metadata:37 name: openai-wif-app38 namespace: default39spec:40 serviceAccountName: openai-wif41 containers:42 - name: app43 image: my-image44 volumeMounts:45 - name: ksa-token46 mountPath: /var/run/secrets/tokens47 readOnly: true48 volumes:49 - name: ksa-token50 projected:51 sources:52 - serviceAccountToken:53 path: token54 audience: "https://api.openai.com/v1"55 expirationSeconds: 360056```5758## Verify the token5960Before configuring workload identity federation, decode a sample projected service account token locally and inspect its claims. From a running pod with the projected token mounted, retrieve the token and export it as `TOKEN`:6162```bash63TOKEN=$(kubectl exec -n default openai-wif-app -- cat /var/run/secrets/tokens/token)64export TOKEN65```6667Then run this script:6869```javascript70const parts = process.env.TOKEN?.split(".") ?? [];71if (parts.length !== 3) {72 throw new Error("Expected a compact JWT with three segments");73}74if (!/^[A-Za-z0-9_-]+$/.test(parts[1]) || parts[1].length % 4 === 1) {75 throw new Error("JWT payload is not valid Base64URL");76}7778const bytes = Buffer.from(parts[1], "base64url");79if (bytes.toString("base64url") !== parts[1]) {80 throw new Error("JWT payload is not valid Base64URL");81}82const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);83const claims = JSON.parse(decoded);84if (claims === null || Array.isArray(claims) || typeof claims !== "object") {85 throw new Error("JWT payload is not a JSON object");86}87console.log(decoded);88```8990```python91import base6492import json93import os94import re959697def reject_non_json_constant(value):98 raise ValueError(f"JWT payload contains non-JSON constant: {value}")99100101parts = os.environ.get("TOKEN", "").split(".")102if len(parts) != 3:103 raise ValueError("Expected a compact JWT with three segments")104105payload = parts[1]106if re.fullmatch(r"[A-Za-z0-9_-]+", payload) is None or len(payload) % 4 == 1:107 raise ValueError("JWT payload is not valid Base64URL")108padded_payload = payload + "=" * (-len(payload) % 4)109decoded = base64.b64decode(padded_payload, altchars=b"-_", validate=True)110if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode("ascii") != payload:111 raise ValueError("JWT payload is not valid Base64URL")112decoded_text = decoded.decode("utf-8")113claims = json.loads(decoded_text, parse_constant=reject_non_json_constant)114if not isinstance(claims, dict):115 raise ValueError("JWT payload is not a JSON object")116print(decoded_text)117```118119```go120package main121122import (123 "bytes"124 "encoding/base64"125 "encoding/json"126 "fmt"127 "os"128 "strings"129 "unicode/utf8"130)131132func decodeSegment(segment string) (json.RawMessage, error) {133 if !isBase64URLSegment(segment) {134 return nil, fmt.Errorf("JWT segment is not valid Base64URL")135 }136 decoded, err := base64.RawURLEncoding.DecodeString(segment)137 if err != nil {138 return nil, err139 }140 if base64.RawURLEncoding.EncodeToString(decoded) != segment {141 return nil, fmt.Errorf("JWT segment is not valid Base64URL")142 }143 if !utf8.Valid(decoded) {144 return nil, fmt.Errorf("JWT segment is not valid UTF-8")145 }146147 var value json.RawMessage148 if err := json.Unmarshal(decoded, &value); err != nil {149 return nil, err150 }151 if trimmed := bytes.TrimSpace(value); len(trimmed) == 0 || trimmed[0] != '{' {152 return nil, fmt.Errorf("JWT segment is not a JSON object")153 }154 return value, nil155}156157func isBase64URLSegment(segment string) bool {158 if segment == "" || len(segment)%4 == 1 {159 return false160 }161 for _, character := range segment {162 if !('A' <= character && character <= 'Z') &&163 !('a' <= character && character <= 'z') &&164 !('0' <= character && character <= '9') &&165 character != '-' &&166 character != '_' {167 return false168 }169 }170 return true171}172173func main() {174 parts := strings.Split(os.Getenv("TOKEN"), ".")175 if len(parts) != 3 {176 panic("Expected a compact JWT with three segments")177 }178179 payload, err := decodeSegment(parts[1])180 if err != nil {181 panic(err)182 }183 formatted, err := json.MarshalIndent(payload, "", " ")184 if err != nil {185 panic(err)186 }187 fmt.Println(string(formatted))188}189```190191```java192// Add Jackson (com.fasterxml.jackson.core:jackson-databind) to your project.193import com.fasterxml.jackson.databind.DeserializationFeature;194import com.fasterxml.jackson.databind.JsonNode;195import com.fasterxml.jackson.databind.ObjectMapper;196import java.io.IOException;197import java.nio.ByteBuffer;198import java.nio.charset.CharacterCodingException;199import java.nio.charset.CodingErrorAction;200import java.nio.charset.StandardCharsets;201import java.util.Base64;202203public final class DecodeJwtPayloadExample {204 private static final ObjectMapper JSON =205 new ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);206207 private DecodeJwtPayloadExample() {}208209 static String decodeUtf8(byte[] bytes) throws IOException {210 try {211 return StandardCharsets.UTF_8212 .newDecoder()213 .onMalformedInput(CodingErrorAction.REPORT)214 .onUnmappableCharacter(CodingErrorAction.REPORT)215 .decode(ByteBuffer.wrap(bytes))216 .toString();217 } catch (CharacterCodingException exception) {218 throw new IOException("JWT segment is not valid UTF-8", exception);219 }220 }221222 static String decodeSegment(String segment) throws IOException {223 if (!isBase64UrlSegment(segment)) {224 throw new IllegalArgumentException("JWT segment is not valid Base64URL");225 }226 byte[] bytes = Base64.getUrlDecoder().decode(segment);227 if (!Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).equals(segment)) {228 throw new IllegalArgumentException("JWT segment is not valid Base64URL");229 }230 String decoded = decodeUtf8(bytes);231 JsonNode value = JSON.readTree(decoded);232 if (value == null || value.isMissingNode() || !value.isObject()) {233 throw new IOException("JWT segment is not a JSON object");234 }235 return decoded;236 }237238 static boolean isBase64UrlSegment(String segment) {239 if (segment.isEmpty() || segment.length() % 4 == 1) {240 return false;241 }242 return segment243 .chars()244 .allMatch(245 character ->246 character >= 'A' && character <= 'Z'247 || character >= 'a' && character <= 'z'248 || character >= '0' && character <= '9'249 || character == '-'250 || character == '_');251 }252253 static String[] requireCompactJwt(String token) {254 if (token == null) {255 throw new IllegalArgumentException("Expected a compact JWT with three segments");256 }257 String[] parts = token.split("\\.", -1);258 if (parts.length != 3) {259 throw new IllegalArgumentException("Expected a compact JWT with three segments");260 }261 return parts;262 }263264 public static void main(String[] args) throws IOException {265 String[] parts = requireCompactJwt(System.getenv("TOKEN"));266 System.out.println(decodeSegment(parts[1]));267 }268}269```270271```csharp272using System.Text;273using System.Text.Json;274275static string DecodeSegment(string segment)276{277 if (278 segment.Length % 4 == 1 ||279 segment.Any(280 character =>281 !(282 character is >= 'A' and <= 'Z' ||283 character is >= 'a' and <= 'z' ||284 character is >= '0' and <= '9' ||285 character is '-' or '_'286 )287 )288 )289 {290 throw new FormatException("JWT segment is not valid Base64URL");291 }292293 byte[] decoded = Convert.FromBase64String(294 segment.Replace('-', '+').Replace('_', '/') +295 new string('=', (4 - segment.Length % 4) % 4)296 );297 string canonicalSegment = Convert298 .ToBase64String(decoded)299 .TrimEnd('=')300 .Replace('+', '-')301 .Replace('/', '_');302 if (canonicalSegment != segment)303 {304 throw new FormatException("JWT segment is not valid Base64URL");305 }306 string decodedJson = new UTF8Encoding(false, true).GetString(decoded);307 using JsonDocument document = JsonDocument.Parse(decodedJson);308 if (document.RootElement.ValueKind is not JsonValueKind.Object)309 {310 throw new FormatException("JWT segment is not a JSON object");311 }312 return decodedJson;313}314315string? token = Environment.GetEnvironmentVariable("TOKEN");316if (token is null)317{318 throw new InvalidOperationException(319 "Expected a compact JWT with three segments"320 );321}322string[] parts = token.Split('.');323if (parts.Length != 3)324{325 throw new InvalidOperationException(326 "Expected a compact JWT with three segments"327 );328}329330Console.WriteLine(DecodeSegment(parts[1]));331```332333```ruby334require "base64"335require "json"336337parts = ENV.fetch("TOKEN", "").split(".", -1)338raise "Expected a compact JWT with three segments" unless parts.length == 3339340unless parts[1].match?(/\A[A-Za-z0-9_-]+\z/) && parts[1].length % 4 != 1341 raise "JWT payload is not valid Base64URL"342end343344begin345 payload = Base64.urlsafe_decode64(parts[1].ljust((parts[1].length + 3) & ~3, "="))346rescue ArgumentError347 raise "JWT payload is not valid Base64URL"348end349unless Base64.urlsafe_encode64(payload, padding: false) == parts[1]350 raise "JWT payload is not valid Base64URL"351end352353payload.force_encoding(Encoding::UTF_8)354raise "JWT payload is not valid UTF-8" unless payload.valid_encoding?355356claims = JSON.parse(payload)357raise "JWT payload is not a JSON object" unless claims.is_a?(Hash)358359puts(payload)360```361362363This command decodes the JWT payload without verifying the token signature. Use a local decoder for production tokens, and avoid pasting production tokens into third-party tools.364365A decoded Kubernetes projected service account token will look similar to:366367```json368{369 "iss": "https://kubernetes.example.com",370 "aud": ["https://api.openai.com/v1"],371 "sub": "system:serviceaccount:default:openai-wif",372 "iat": 1716235422,373 "exp": 1716239022,374 "kubernetes.io": {375 "namespace": "default",376 "serviceaccount": {377 "name": "openai-wif",378 "uid": "11111111-2222-3333-4444-555555555555"379 }380 }381}382```383384Use the decoded payload to compare the token you received with the issuer, audience, and mapping values configured in OpenAI. Most configuration issues are visible in the `iss`, `aud`, and `sub` claims before you exchange the token.385386## Setting up workload identity federation387388Create a Workload Identity Provider in OpenAI for the Kubernetes issuer, then add a service account mapping that matches attributes from the projected token.389390Configure the Workload Identity Provider first, then create the service account mapping.391392### Set up the Workload Identity Provider3933941. **Create the Workload Identity Provider.** Set **Name** to a unique value, such as `kubernetes-prod`. Use **Description**, such as `Production Kubernetes cluster`, to help admins identify the cluster.3953962. **Set the issuer and audience.** Set **OIDC Issuer URL** to the issuer returned by `kubectl get --raw /.well-known/openid-configuration | jq -r .issuer`. This value must match the `iss` claim in the projected token. Set **Audience** to the same opaque audience string configured on the projected service account token volume. In this example, that value is `https://api.openai.com/v1`.3973983. **Upload the Kubernetes JWKS.** Enable **Use uploaded JWKS for token verification**, then set **JWKS JSON** to the output from `kubectl get --raw /openid/v1/jwks`. OpenAI uses this public key set to verify projected Kubernetes service account tokens. Upload the full key set including the surrounding `keys`.399400 > **Note:** For self-hosted Kubernetes clusters, OpenAI supports only local JWKS mode. Upload the JWKS returned by your cluster; OpenAI does not perform OIDC discovery against the configured issuer. OpenAI still compares the configured issuer with the `iss` field in the token.401402 If your cluster rotates service account signing keys, update the uploaded JWKS in the Workload Identity Provider configuration. Tokens signed by keys that are not present in the configured JWKS are rejected. If the JWKS contains multiple active public keys, include the full `keys` array.4034044. **Add attribute transformations only if you need derived mapping attributes.** Raw token claims such as `sub`, `aud`, and `iss` can be used directly in mapping assertions. If you plan to match on transformed attributes rather than raw token claims, the dashboard applies the `openai.` prefix automatically; for example, enter `workload_subject` with expression `assertion.sub` to create `openai.workload_subject`. Raw token claims that already start with `openai.` are ignored for `openai.` mapping keys unless a matching transformation is configured.405406### Set up the service account mapping4074081. **Create a service account mapping.** Set **Name** to a unique value within the Workload Identity Provider, such as `openai-mapping-kubernetes`. Use **Description**, such as `Workload Identity Provider Mapping for Kubernetes Workloads`, to explain which workload can use the mapping.4094102. **Match the Kubernetes service account subject.** Set **Key** to `sub` and **Value** to `system:serviceaccount:default:openai-wif`. For Kubernetes service accounts, the subject format is `system:serviceaccount:<namespace>:<service-account-name>`.4114123. **Choose the OpenAI target.** Set **Project** to the OpenAI project that owns the target service account. Set **Service account** to the OpenAI service account the Kubernetes workload can use, such as `kubernetes-prod-openai-wif`. Check `Create a new service account in this project` if you wish to create a new service account for this mapping rather than reuse an existing one.4134144. **Narrow API permissions if needed.** Select appropriate **Permissions** such as `api.model.request` and `api.vector_store.read` to further narrow access tokens minted from this mapping. Leave permissions blank to avoid adding a WIF-specific scope restriction; the token still authorizes as the mapped service account.415416## Using the token in code417418Configure your OpenAI SDK client to read the projected Kubernetes token and exchange it for an OpenAI-issued access token.419420Use the mounted token path, such as `/var/run/secrets/tokens/token`, as the subject token source for the SDK workload identity federation provider. The SDK exchanges that Kubernetes token for an OpenAI-issued access token and uses the OpenAI token to authenticate API requests.421422The following examples initialize an OpenAI client with a custom subject token provider. The provider reads the projected Kubernetes service account token from the mounted file path and uses it as the subject token for workload identity federation.423424Authenticate from a Kubernetes projected service account token425426```javascript427import { readFile } from "node:fs/promises";428import OpenAI from "openai";429430const tokenPath = "/var/run/secrets/tokens/token";431const identityProviderId = process.env.OPENAI_IDENTITY_PROVIDER_ID;432const serviceAccountId = process.env.OPENAI_SERVICE_ACCOUNT_ID;433434if (!identityProviderId || !serviceAccountId) {435 throw new Error(436 "Set OPENAI_IDENTITY_PROVIDER_ID and OPENAI_SERVICE_ACCOUNT_ID"437 );438}439440function mountedServiceAccountTokenProvider(path) {441 return {442 tokenType: "jwt",443 getToken: async () => {444 const token = (await readFile(path, "utf8")).trim();445 if (!token) {446 throw new Error("The mounted service account token file is empty.");447 }448 return token;449 },450 };451}452453const client = new OpenAI({454 workloadIdentity: {455 identityProviderId,456 serviceAccountId,457 provider: mountedServiceAccountTokenProvider(tokenPath),458 },459});460461const response = await client.responses.create({462 model: "gpt-5.6-terra",463 input: "Say hello from Kubernetes workload identity federation.",464});465466console.log(response.output_text);467```468469```python470import os471from pathlib import Path472473from openai import OpenAI474from openai.auth import SubjectTokenProvider475476TOKEN_PATH = "/var/run/secrets/tokens/token"477478479def mounted_service_account_token_provider(token_path: str) -> SubjectTokenProvider:480 def get_token() -> str:481 token = Path(token_path).read_text().strip()482 if not token:483 raise RuntimeError("The mounted service account token file is empty.")484 return token485486 return {"token_type": "jwt", "get_token": get_token}487488489client = OpenAI(490 workload_identity={491 "identity_provider_id": os.environ["OPENAI_IDENTITY_PROVIDER_ID"],492 "service_account_id": os.environ["OPENAI_SERVICE_ACCOUNT_ID"],493 "provider": mounted_service_account_token_provider(TOKEN_PATH),494 },495)496497response = client.responses.create(498 model="gpt-5.6-terra",499 input="Say hello from Kubernetes workload identity federation.",500)501502print(response.output_text)503```504505```go506package main507508import (509 "context"510 "fmt"511 "log"512 "os"513 "strings"514515 "github.com/openai/openai-go/v3"516 "github.com/openai/openai-go/v3/auth"517 "github.com/openai/openai-go/v3/option"518 "github.com/openai/openai-go/v3/responses"519)520521const tokenPath = "/var/run/secrets/tokens/token"522523type mountedServiceAccountTokenProvider struct {524 path string525}526527func (p mountedServiceAccountTokenProvider) TokenType() auth.SubjectTokenType {528 return auth.SubjectTokenTypeJWT529}530531func (p mountedServiceAccountTokenProvider) GetToken(ctx context.Context, _ auth.HTTPDoer) (string, error) {532 data, err := os.ReadFile(p.path)533 if err != nil {534 return "", &auth.SubjectTokenProviderError{535 Provider: "kubernetes",536 Message: "failed to read mounted service account token",537 Cause: err,538 }539 }540541 token := strings.TrimSpace(string(data))542 if token == "" {543 return "", &auth.SubjectTokenProviderError{544 Provider: "kubernetes",545 Message: "mounted service account token is empty",546 }547 }548549 return token, nil550}551552func main() {553 client := openai.NewClient(554 option.WithWorkloadIdentity(auth.WorkloadIdentity{555 IdentityProviderID: os.Getenv("OPENAI_IDENTITY_PROVIDER_ID"),556 ServiceAccountID: os.Getenv("OPENAI_SERVICE_ACCOUNT_ID"),557 Provider: mountedServiceAccountTokenProvider{558 path: tokenPath,559 },560 }),561 )562563 response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{564 Model: openai.ChatModelGPT4_1Mini,565 Input: responses.ResponseNewParamsInputUnion{566 OfString: openai.String("Say hello from Kubernetes workload identity federation."),567 },568 })569 if err != nil {570 log.Fatal(err)571 }572573 fmt.Println(response.OutputText())574}575```576577```java578import com.fasterxml.jackson.databind.json.JsonMapper;579import com.openai.auth.SubjectTokenProvider;580import com.openai.auth.SubjectTokenType;581import com.openai.auth.WorkloadIdentity;582import com.openai.client.OpenAIClient;583import com.openai.client.okhttp.OpenAIOkHttpClient;584import com.openai.core.http.HttpClient;585import com.openai.errors.SubjectTokenProviderException;586import com.openai.models.responses.ResponseCreateParams;587import java.nio.file.Files;588import java.nio.file.Path;589import java.util.concurrent.CompletableFuture;590591public final class KubernetesWorkloadIdentityExample {592 private static final String TOKEN_PATH = "/var/run/secrets/tokens/token";593594 private KubernetesWorkloadIdentityExample() {}595596 static final class MountedServiceAccountTokenProvider implements SubjectTokenProvider {597 private final Path tokenPath;598599 MountedServiceAccountTokenProvider(String tokenPath) {600 this.tokenPath = Path.of(tokenPath);601 }602603 @Override604 public SubjectTokenType tokenType() {605 return SubjectTokenType.JWT;606 }607608 @Override609 public String getToken(HttpClient httpClient, JsonMapper jsonMapper) {610 String token;611 try {612 token = Files.readString(tokenPath).trim();613 } catch (Exception e) {614 throw new SubjectTokenProviderException(615 "kubernetes", "failed to read mounted service account token", e);616 }617618 if (token.isEmpty()) {619 throw new SubjectTokenProviderException(620 "kubernetes", "mounted service account token is empty", null);621 }622623 return token;624 }625626 @Override627 public CompletableFuture<String> getTokenAsync(HttpClient httpClient, JsonMapper jsonMapper) {628 return CompletableFuture.supplyAsync(() -> getToken(httpClient, jsonMapper));629 }630 }631632 public static void main(String[] args) {633 WorkloadIdentity workloadIdentity =634 WorkloadIdentity.builder()635 .identityProviderId(System.getenv("OPENAI_IDENTITY_PROVIDER_ID"))636 .serviceAccountId(System.getenv("OPENAI_SERVICE_ACCOUNT_ID"))637 .provider(new MountedServiceAccountTokenProvider(TOKEN_PATH))638 .build();639640 OpenAIClient client = OpenAIOkHttpClient.builder().workloadIdentity(workloadIdentity).build();641642 ResponseCreateParams params =643 ResponseCreateParams.builder()644 .model("gpt-5.6-terra")645 .input("Say hello from Kubernetes workload identity federation.")646 .build();647648 client.responses().create(params).output().stream()649 .flatMap(item -> item.message().stream())650 .flatMap(message -> message.content().stream())651 .flatMap(content -> content.outputText().stream())652 .forEach(outputText -> System.out.println(outputText.text()));653 }654}655```656657```ruby658require "openai"659660TOKEN_PATH = "/var/run/secrets/tokens/token"661662class MountedServiceAccountTokenProvider663 include OpenAI::Auth::SubjectTokenProvider664665 def initialize(token_path:)666 @token_path = token_path667 end668669 def token_type670 OpenAI::Auth::TokenType::JWT671 end672673 def get_token674 token = File.read(@token_path).strip675 if token.empty?676 raise OpenAI::Errors::SubjectTokenProviderError.new(677 message: "Mounted service account token is empty",678 provider: "kubernetes"679 )680 end681 token682 rescue SystemCallError => e683 raise OpenAI::Errors::SubjectTokenProviderError.new(684 message: "Failed to read mounted service account token: #{e.message}",685 provider: "kubernetes",686 cause: e687 )688 end689end690691provider = MountedServiceAccountTokenProvider.new(token_path: TOKEN_PATH)692693workload_identity = OpenAI::Auth::WorkloadIdentity.new(694 identity_provider_id: ENV.fetch("OPENAI_IDENTITY_PROVIDER_ID"),695 service_account_id: ENV.fetch("OPENAI_SERVICE_ACCOUNT_ID"),696 provider: provider697)698699client = OpenAI::Client.new(workload_identity: workload_identity)700701response = client.responses.create(702 model: "gpt-5.6-terra",703 input: "Say hello from Kubernetes workload identity federation."704)705706puts(response.output_text)707```708709710## Kubernetes best practices711712- Use a stable OIDC issuer. The issuer URL must match the projected service account token `iss` claim and should remain stable across cluster upgrades and maintenance operations.713- Protect signing keys carefully. Anyone with access to the cluster's service account signing keys can mint tokens that may be accepted by OpenAI.714- Use dedicated service accounts for OpenAI integrations. Avoid reusing service accounts that are also used for unrelated infrastructure or application access.715- Keep the uploaded JWKS current. OpenAI uses the configured JWKS to validate workload identity tokens in local JWKS mode, so update the Workload Identity Provider before rotating to new signing keys.716- Minimize custom claim complexity. Prefer matching on standard claims such as `sub` and `aud`, or transformed attributes derived directly from those claims.717- Treat namespace ownership as part of your security model. If namespace administrators can create service accounts, ensure mappings are scoped appropriately to prevent unintended privilege escalation.718- Monitor issuer and signing key changes. Rotating signing keys without updating the Workload Identity Provider JWKS can cause token exchange failures.