cwii (Cluster Workload Identity Injector) is a Rust Kubernetes mutating admission
webhook that lets pods on self-hosted clusters authenticate to GCP, AWS and
Azure using their Kubernetes ServiceAccount tokens — workload identity federation
with no static keys. You drive the injector almost entirely through
cwii.dev/* annotations on your pods and the objects that own or scope them.
This page is the complete reference for every annotation cwii reads or writes, the precedence model that resolves them, and a set of copy-pasteable recipes.
[!NOTE] Annotations only take effect once the webhook is installed and your cluster publishes a valid OIDC discovery document. See install and the hard prerequisite in self-hosted OIDC. For the opt-in
can-ichecks, see verification.
Throughout this document the provider abbreviations are:
| Provider | Abbr. (<p>) |
Mechanism |
|---|---|---|
| Google Cloud | gcp |
external_account credentials (direct federation or impersonation) |
| Amazon Web Services | aws |
AssumeRoleWithWebIdentity |
| Microsoft Azure | az |
Entra ID federated identity credential |
All annotation keys use the cwii.dev/ prefix and are reproduced verbatim below.
Every key except cwii.dev/injected is an input you set; cwii.dev/injected
is an output the webhook writes onto the mutated pod.
These keys exist for each of the three providers. Substitute <p> with gcp,
aws or az.
| Annotation | Provider | Type | Default | Meaning |
|---|---|---|---|---|
cwii.dev/gcp-inject |
gcp | "true" / "false" |
unset (no injection) | Enable GCP injection for the pod. |
cwii.dev/aws-inject |
aws | "true" / "false" |
unset (no injection) | Enable AWS injection for the pod. Also requires cwii.dev/aws-role-arn. |
cwii.dev/az-inject |
az | "true" / "false" |
unset (no injection) | Enable Azure injection for the pod. Also requires cwii.dev/az-client-id and cwii.dev/az-tenant-id. |
cwii.dev/gcp-audience |
gcp | string | server --gcp-default-audience |
Override the projected-token audience for GCP. |
cwii.dev/aws-audience |
aws | string | sts.amazonaws.com |
Override the projected-token audience for AWS. |
cwii.dev/az-audience |
az | string | api://AzureADTokenExchange |
Override the projected-token audience for Azure. |
cwii.dev/gcp-token-expiration |
gcp | integer (seconds) | 3600 (Kubernetes min 600) |
Projected token lifetime for GCP. |
cwii.dev/aws-token-expiration |
aws | integer (seconds) | 3600 (Kubernetes min 600) |
Projected token lifetime for AWS. |
cwii.dev/az-token-expiration |
az | integer (seconds) | 3600 (Kubernetes min 600) |
Projected token lifetime for Azure. |
cwii.dev/gcp-verify |
gcp | "true" / "false" |
false |
Add a non-blocking can-i init container for GCP. |
cwii.dev/aws-verify |
aws | "true" / "false" |
false |
Add a non-blocking can-i init container for AWS. |
cwii.dev/az-verify |
az | "true" / "false" |
false |
Add a non-blocking can-i init container for Azure. |
cwii.dev/gcp-verify-enforce |
gcp | "true" / "false" |
false |
Make a failed GCP verify block pod startup. |
cwii.dev/aws-verify-enforce |
aws | "true" / "false" |
false |
Make a failed AWS verify block pod startup. |
cwii.dev/az-verify-enforce |
az | "true" / "false" |
false |
Make a failed Azure verify block pod startup. |
cwii.dev/gcp-verify-image |
gcp | string (image ref) | server --gcp-verify-image (google/cloud-sdk:slim) |
Override the GCP verify init-container image. |
cwii.dev/aws-verify-image |
aws | string (image ref) | server --aws-verify-image (amazon/aws-cli:latest) |
Override the AWS verify init-container image. |
cwii.dev/az-verify-image |
az | string (image ref) | server --az-verify-image (mcr.microsoft.com/azure-cli:latest) |
Override the Azure verify init-container image. |
| Annotation | Provider | Type | Default | Meaning |
|---|---|---|---|---|
cwii.dev/gcp-service-account |
gcp | string (GSA email) | unset (direct federation) | Google service account email to impersonate. When set, cwii adds the impersonation URL to credentials.json; when unset, the pod federates directly. |
cwii.dev/gcp-delivery |
gcp | config-map / init-container |
server --gcp-delivery |
How credentials.json reaches the pod: a server-side-applied ConfigMap, or an init container that writes the file into an emptyDir. |
| Annotation | Provider | Type | Default | Meaning |
|---|---|---|---|---|
cwii.dev/aws-role-arn |
aws | string (role ARN) | none — REQUIRED to inject | IAM role to assume. Sets AWS_ROLE_ARN. Without it, AWS injection does not happen. |
cwii.dev/aws-region |
aws | string | unset | Optional region. Sets AWS_REGION. |
cwii.dev/aws-role-session-name |
aws | string | unset | Optional session name. Sets AWS_ROLE_SESSION_NAME. |
| Annotation | Provider | Type | Default | Meaning |
|---|---|---|---|---|
cwii.dev/az-client-id |
az | string (UUID) | none — REQUIRED | Entra ID application/client ID. Sets AZURE_CLIENT_ID. Required to inject. |
cwii.dev/az-tenant-id |
az | string (UUID) | none — REQUIRED | Entra ID tenant ID. Sets AZURE_TENANT_ID. Required to inject. |
cwii.dev/az-authority-host |
az | string (URL) | unset | Optional authority host. Sets AZURE_AUTHORITY_HOST. |
| Annotation | Provider | Type | Default | Meaning |
|---|---|---|---|---|
cwii.dev/injected |
— | string | — (written by webhook) | Comma-joined, sorted list of provider abbreviations that were injected, e.g. aws,gcp. Set by the webhook on the mutated pod; do not set it yourself. |
[!TIP]
cwii.dev/injectedis the fastest way to confirm a mutation took effect:kubectl get pod <name> -o jsonpath='{.metadata.annotations.cwii\.dev/injected}'.
Understanding the precedence model is easier once you know what each enabled provider actually adds to the pod.
Each enabled provider gets its own projected serviceAccountToken volume.
Providers mount separately because each cloud needs a different token
audience.
| Property | Value |
|---|---|
| Volume name | cwii-<p>-token |
| Mount path | /var/run/secrets/cwii.dev/<p> (read-only) |
| File | token |
| Token path | /var/run/secrets/cwii.dev/<p>/token |
expirationSeconds |
3600 default, minimum 600 |
The projected tokens are standard OIDC JWTs whose sub claim is
system:serviceaccount:<NS>:<SA>, and whose aud is the provider audience
above (overridable per provider via cwii.dev/<p>-audience).
cwii builds a Google external_account credentials.json:
{
"type": "external_account",
"audience": "<gcp-audience>",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"token_info_url": "https://sts.googleapis.com/v1/introspect",
"credential_source": {
"file": "/var/run/secrets/cwii.dev/gcp/token"
}
}
If cwii.dev/gcp-service-account is set, cwii additionally inserts:
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/<GSA>:generateAccessToken"
Otherwise the pod uses direct federation (no impersonation URL).
The file is mounted at /var/run/secrets/cwii.dev/gcp-creds/credentials.json
and GOOGLE_APPLICATION_CREDENTIALS points there.
Delivery is controlled by cwii.dev/gcp-delivery:
| Delivery | How credentials.json is delivered |
Cluster writes? |
|---|---|---|
config-map |
Webhook server-side-applies a ConfigMap named cwii-gcp-creds-<6 hex of sha256(audience+NUL+sa)>, labeled app.kubernetes.io/managed-by=cwii, mounted via a configMap volume cwii-gcp-creds. |
Yes — needs ConfigMap-write RBAC. |
init-container |
An emptyDir volume cwii-gcp-creds plus an init container cwii-gcp-creds-writer (image busybox:stable) that writes credentials.json from env CWII_GCP_CREDS_JSON. |
No cluster writes. |
AWS injection is env-vars only (no file beyond the projected token):
| Env var | Source / value |
|---|---|
AWS_ROLE_ARN |
cwii.dev/aws-role-arn |
AWS_WEB_IDENTITY_TOKEN_FILE |
/var/run/secrets/cwii.dev/aws/token |
AWS_REGION |
cwii.dev/aws-region (optional) |
AWS_ROLE_SESSION_NAME |
cwii.dev/aws-role-session-name (optional) |
Token audience defaults to sts.amazonaws.com. The mechanism is
AssumeRoleWithWebIdentity.
Azure injection is env-vars only:
| Env var | Source / value |
|---|---|
AZURE_CLIENT_ID |
cwii.dev/az-client-id |
AZURE_TENANT_ID |
cwii.dev/az-tenant-id |
AZURE_FEDERATED_TOKEN_FILE |
/var/run/secrets/cwii.dev/az/token |
AZURE_AUTHORITY_HOST |
cwii.dev/az-authority-host (optional) |
Token audience defaults to api://AzureADTokenExchange. The mechanism is an
Entra ID federated identity credential.
can-i) init containersOpt-in via cwii.dev/<p>-verify. They run at order 10 (after the GCP
creds writer at order 0).
| Provider | Container | Command | Default image |
|---|---|---|---|
| gcp | cwii-gcp-verify |
gcloud auth application-default print-access-token |
google/cloud-sdk:slim |
| aws | cwii-aws-verify |
aws sts get-caller-identity |
amazon/aws-cli:latest |
| az | cwii-az-verify |
az login --service-principal ... --federated-token ... && az account show |
mcr.microsoft.com/azure-cli:latest |
By default the check is non-blocking — it is wrapped as
<check> || echo ... >&2, so it always exits 0 and only logs. With
cwii.dev/<p>-verify-enforce the check runs bare, so a non-zero exit blocks
pod startup. Override the image with cwii.dev/<p>-verify-image or the Helm
value providers.<p>.verifyImage. See verification for
details.
cwii resolves each annotation from four scopes, in this order:
pod > owning workload > ServiceAccount > namespace
Three rules make this predictable:
false beats broader true. Because the first explicit value
wins, a pod-level cwii.dev/gcp-inject: "false" suppresses a namespace-level
cwii.dev/gcp-inject: "true". One provider never affects another.When resolving the “owning workload” scope, cwii walks owner references:
ReplicaSet -> Deployment (Deployment annotations are preferred over the
intermediate ReplicaSet), plus StatefulSet, DaemonSet and Job.
Example 1 — namespace opt-in, pod opt-out (specific false wins).
| Scope | cwii.dev/gcp-inject |
|---|---|
| Namespace | "true" |
| Pod | "false" |
Result: GCP is not injected. The pod-level false is the first explicit
value walking outward and wins for the gcp-inject key.
Example 2 — independent per key across providers and scopes.
| Scope | cwii.dev/gcp-inject |
cwii.dev/aws-inject |
cwii.dev/aws-role-arn |
|---|---|---|---|
| Namespace | "true" |
— | — |
| ServiceAccount | — | "true" |
arn:aws:iam::111122223333:role/data |
| Pod | — | — | — |
Result: both GCP and AWS are injected. gcp-inject resolves from the namespace;
aws-inject and aws-role-arn resolve independently from the ServiceAccount.
cwii.dev/injected becomes aws,gcp (sorted).
Example 3 — Deployment preferred over ReplicaSet, overridden by pod.
| Scope | cwii.dev/aws-audience |
|---|---|
| Deployment | sts.amazonaws.com |
| Pod template (becomes the Pod) | sts.eu-west-1.amazonaws.com |
Result: the audience is sts.eu-west-1.amazonaws.com. The pod’s own annotation
wins. (Between a ReplicaSet and its Deployment, the Deployment’s value would
win.)
Turn on GCP federation for everything in a namespace, then bind the ServiceAccount in GCP as a federated principal.
kubectl annotate namespace team-analytics cwii.dev/gcp-inject=true
apiVersion: v1
kind: Namespace
metadata:
name: team-analytics
annotations:
cwii.dev/gcp-inject: "true"
cwii.dev/gcp-audience: "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/onprem/providers/k8s"
On the GCP side, grant the federated principal access (direct federation):
gcloud projects add-iam-policy-binding my-project \
--role roles/storage.objectViewer \
--member "principal://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/onprem/subject/system:serviceaccount:team-analytics:default"
cwii.dev/aws-role-arn is required for AWS injection. Set it (and the toggle)
on the Deployment so every pod inherits it via the owner walk.
apiVersion: apps/v1
kind: Deployment
metadata:
name: ingest
namespace: pipelines
annotations:
cwii.dev/aws-inject: "true"
cwii.dev/aws-role-arn: "arn:aws:iam::111122223333:role/cwii-ingest"
cwii.dev/aws-region: "eu-west-1"
spec:
template:
metadata: {}
spec:
containers:
- name: app
image: ghcr.io/example/ingest:latest
The matching IAM trust policy (OIDC provider already registered for your cluster issuer):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/oidc.example.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.example.com:aud": "sts.amazonaws.com",
"oidc.example.com:sub": "system:serviceaccount:pipelines:default"
}
}
}]
}
Each enabled provider gets its own token volume and its own audience, so a single pod can talk to all three clouds at once.
apiVersion: v1
kind: Pod
metadata:
name: multi-cloud
namespace: workloads
annotations:
cwii.dev/gcp-inject: "true"
cwii.dev/gcp-service-account: "data-reader@my-project.iam.gserviceaccount.com"
cwii.dev/aws-inject: "true"
cwii.dev/aws-role-arn: "arn:aws:iam::111122223333:role/cwii-multi"
cwii.dev/az-inject: "true"
cwii.dev/az-client-id: "00000000-0000-0000-0000-000000000000"
cwii.dev/az-tenant-id: "11111111-1111-1111-1111-111111111111"
spec:
containers:
- name: app
image: ghcr.io/example/multi:latest
After admission, cwii.dev/injected on the pod will read aws,az,gcp.
The pod ends up with three projected token volumes
(cwii-gcp-token, cwii-aws-token, cwii-az-token) mounted under
/var/run/secrets/cwii.dev/{gcp,aws,az}.
Use a specific false to suppress a broader true for a single provider,
without affecting the others.
apiVersion: v1
kind: Pod
metadata:
name: no-gcp-here
namespace: team-analytics # namespace sets cwii.dev/gcp-inject: "true"
annotations:
cwii.dev/gcp-inject: "false" # this pod opts out of GCP only
spec:
containers:
- name: app
image: ghcr.io/example/app:latest
Set cwii.dev/gcp-service-account to a GSA email. cwii adds the impersonation
URL to credentials.json instead of federating directly.
metadata:
annotations:
cwii.dev/gcp-inject: "true"
cwii.dev/gcp-service-account: "data-reader@my-project.iam.gserviceaccount.com"
Allow the federated principal to impersonate the GSA:
gcloud iam service-accounts add-iam-policy-binding \
data-reader@my-project.iam.gserviceaccount.com \
--role roles/iam.workloadIdentityUser \
--member "principal://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/onprem/subject/system:serviceaccount:workloads:default"
Avoid granting the webhook ConfigMap-write RBAC by delivering credentials.json
through an init container into an emptyDir.
metadata:
annotations:
cwii.dev/gcp-inject: "true"
cwii.dev/gcp-delivery: "init-container"
This injects an emptyDir volume cwii-gcp-creds and an init container
cwii-gcp-creds-writer (image busybox:stable) that writes credentials.json
from the env var CWII_GCP_CREDS_JSON. No cluster writes occur, so the
ConfigMap RBAC rule is unnecessary. (With config-map delivery the webhook
needs ConfigMap get/create/update/patch; see install.)
Make a failed can-i check block pod startup so misconfigured federation fails
fast rather than at first API call.
metadata:
annotations:
cwii.dev/aws-inject: "true"
cwii.dev/aws-role-arn: "arn:aws:iam::111122223333:role/cwii-ingest"
cwii.dev/aws-verify: "true"
cwii.dev/aws-verify-enforce: "true"
With aws-verify-enforce: "true", the cwii-aws-verify init container runs
aws sts get-caller-identity bare — a non-zero exit blocks the pod. Without
enforce, the same check is wrapped to always exit 0 and only logs. You can pin
the image per pod:
cwii.dev/aws-verify-image: "amazon/aws-cli:2.17.0"
For drop-in migration of workloads moving off GKE/EKS/AKS to a self-hosted cluster, cwii can
optionally read the managed platforms’ own identity annotations as a fallback. This is off by
default (explicit opt-in is the safe posture) and enabled cluster-wide with the Helm value
compat.nativeAnnotations: true (flag --native-annotations).
When enabled, the presence of a native annotation also triggers injection for that provider
(mirroring how the managed platform behaves), and the native value is used when no cwii.dev/* one
is set. cwii.dev/* annotations always take precedence.
| Native annotation | Platform | Maps to | Notes |
|---|---|---|---|
iam.gke.io/gcp-service-account |
GKE | cwii.dev/gcp-service-account (impersonation) |
GKE’s annotation carries no audience, so you must also set a default gcp-audience (Helm providers.gcp.defaultAudience), else GCP injection is skipped. |
eks.amazonaws.com/role-arn |
EKS | cwii.dev/aws-role-arn |
Audience defaults to sts.amazonaws.com — clean. |
azure.workload.identity/client-id |
AKS | cwii.dev/az-client-id |
Enable signal for Azure when compat is on. |
azure.workload.identity/tenant-id |
AKS | cwii.dev/az-tenant-id |
!!! warning
Leaving this on means any pod/ServiceAccount carrying one of these annotations (e.g. imported
manifests) will be injected. Enable it deliberately, ideally alongside a namespaceSelector
scoping the webhook to the namespaces you’re migrating.
can-i init containers in depth.