Keyless across clouds: identities instead of key files
Most multi-cloud setups are held together by a file. A service-account key from cloud A sits in a secret on cloud B so a pipeline can push images; an access key from B sits in a secret on A so something can read a bucket. Each one is a credential that never expires, that somebody copied once, and that nobody wants to rotate because nobody remembers what would break.
Consider a common shape: builds run on Google Cloud, some workloads run on AWS, and a partner organisation runs its own EKS clusters that need to pull your images. All of it can work with zero stored keys. Here is the pattern, the configuration that makes it real, and the parts that are not free.
The shape
Every serious cloud already runs an identity system that issues short-lived, signed tokens to its workloads. The trick is to let each side accept the other’s tokens directly, through an OpenID Connect trust, instead of translating them into a static key.
Two exchanges, one per direction. Each one is the same three moves: the caller presents a token its own cloud signed, the other cloud checks the signature against the issuer’s public keys, and a narrowly scoped role or service account hands back credentials that expire in an hour.
Push side: Google-built images into ECR
The build runner already has a Google identity, the service account the build runs as. AWS can be told to trust Google as an OpenID provider, and to let exactly that service account assume one role.
On the AWS side, three resources. The trust policy pins the caller to one Google service account by its sub claim, and to the audience you choose when requesting the token:
# aws/iam.tf
resource "aws_iam_openid_connect_provider" "google" {
url = "https://accounts.google.com"
client_id_list = [var.google_token_audience] # e.g. "ecr-push"
}
resource "aws_iam_role" "gcp_build_pusher" {
name = "gcp-build-pusher"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.google.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"accounts.google.com:sub" = var.google_build_sa_unique_id # numeric id of the SA
"accounts.google.com:oaud" = var.google_token_audience
}
}
}]
})
max_session_duration = 3600
}
resource "aws_iam_role_policy" "push_one_repo" {
role = aws_iam_role.gcp_build_pusher.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Effect = "Allow", Action = "ecr:GetAuthorizationToken", Resource = "*" },
{ Effect = "Allow", Resource = aws_ecr_repository.app.arn,
Action = ["ecr:BatchCheckLayerAvailability", "ecr:InitiateLayerUpload", "ecr:UploadLayerPart",
"ecr:CompleteLayerUpload", "ecr:PutImage", "ecr:BatchGetImage"] }
]
})
}
In the build, no secret is read. The runner asks its own metadata server for an ID token with that audience, and the AWS CLI exchanges it:
# cloudbuild step, image with gcloud + aws cli
ID_TOKEN=$(curl -sS -H "Metadata-Flavor: Google" \
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=ecr-push&format=full")
export AWS_ROLE_ARN="arn:aws:iam::123456789012:role/gcp-build-pusher"
export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/token
printf '%s' "$ID_TOKEN" > "$AWS_WEB_IDENTITY_TOKEN_FILE"
aws ecr get-login-password --region eu-west-1 \
| docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-west-1.amazonaws.com
docker push 123456789012.dkr.ecr.eu-west-1.amazonaws.com/app:$SHORT_SHA
The CLI reads the role and token file from the environment and calls AssumeRoleWithWebIdentity itself. When the step ends, the credentials are gone with the container.
Pull side: a partner’s EKS cluster reading Artifact Registry
Every EKS cluster has an OIDC issuer URL, and Kubernetes can project a signed service-account token into any pod with an audience you choose. Google’s Workload Identity Federation can trust that issuer, and map one Kubernetes service account to one Google service account that can only read one registry.
# gcp/federation.tf
resource "google_iam_workload_identity_pool" "partners" {
workload_identity_pool_id = "partner-clusters"
}
resource "google_iam_workload_identity_pool_provider" "partner_a" {
workload_identity_pool_id = google_iam_workload_identity_pool.partners.workload_identity_pool_id
workload_identity_pool_provider_id = "eks-partner-a"
oidc {
issuer_uri = var.partner_cluster_issuer # https://oidc.eks.<region>.amazonaws.com/id/<hash>
allowed_audiences = ["gcp-images"]
}
attribute_mapping = {
"google.subject" = "assertion.sub"
"attribute.namespace" = "assertion['kubernetes.io']['namespace']"
}
# only one namespace, one service account, may exchange tokens
attribute_condition = "assertion.sub == 'system:serviceaccount:apps:image-puller'"
}
resource "google_service_account" "partner_a_puller" {
account_id = "partner-a-puller"
}
resource "google_artifact_registry_repository_iam_member" "partner_a_pull" {
repository = google_artifact_registry_repository.images.name
location = google_artifact_registry_repository.images.location
role = "roles/artifactregistry.reader"
member = "serviceAccount:${google_service_account.partner_a_puller.email}"
}
resource "google_service_account_iam_member" "partner_a_impersonate" {
service_account_id = google_service_account.partner_a_puller.name
role = "roles/iam.workloadIdentityUser"
member = "principal://iam.googleapis.com/projects/${var.project_number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.partners.workload_identity_pool_id}/subject/system:serviceaccount:apps:image-puller"
}
The partner gives you one string, the cluster’s issuer URL. You give them one file, the credential configuration that tells Google’s client libraries where to find the projected token and which provider to present it to:
gcloud iam workload-identity-pools create-cred-config \
projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/partner-clusters/providers/eks-partner-a \
--service-account=partner-a-puller@${PROJECT_ID}.iam.gserviceaccount.com \
--credential-source-file=/var/run/secrets/gcp/token \
--output-file=cred-config.json
Container runtimes do not speak federation, so the last mile is a small job in the partner’s cluster that turns the federated identity into a registry login. It runs every fifty minutes, well inside the one-hour token lifetime:
# partner cluster: refresh the pull secret from a projected token, no key anywhere
apiVersion: batch/v1
kind: CronJob
metadata: { name: refresh-gar-pull-secret, namespace: apps }
spec:
schedule: "*/50 * * * *"
jobTemplate:
spec:
template:
spec:
serviceAccountName: image-puller
restartPolicy: OnFailure
volumes:
- name: gcp-token
projected:
sources:
- serviceAccountToken: { path: token, audience: gcp-images, expirationSeconds: 3600 }
- name: cred-config
configMap: { name: gcp-cred-config }
containers:
- name: refresh
image: gcr.io/google.com/cloudsdktool/google-cloud-cli:slim
volumeMounts:
- { name: gcp-token, mountPath: /var/run/secrets/gcp, readOnly: true }
- { name: cred-config, mountPath: /etc/gcp, readOnly: true }
command: ["/bin/sh", "-c"]
args:
- |
gcloud auth login --cred-file=/etc/gcp/cred-config.json --quiet
TOKEN=$(gcloud auth print-access-token)
kubectl create secret docker-registry gar-pull \
--docker-server=europe-west1-docker.pkg.dev \
--docker-username=oauth2accesstoken --docker-password="$TOKEN" \
--dry-run=client -o yaml | kubectl apply -f -
The image-puller service account needs RBAC to write that one secret, and the deployments reference it as imagePullSecrets. The partner never sees one of your keys. You never see theirs. If you prefer no job at all, a kubelet image credential provider can do the same exchange at pull time; the trust configuration above does not change.
Why bother
The obvious win is that there is nothing to leak. The less obvious one is that every access becomes attributable: the token says which build, which cluster, which namespace. Rotation stops being a project because there is nothing to rotate. Onboarding a second partner cluster is one more provider block and one more binding, in a pull request someone can review.
- No long-lived credentials anywhere, so nothing to leak, rotate or audit for age
- Every token names its workload; access logs tell you who, not just what
- Adding a partner cluster is one trust mapping, no secrets exchanged
- Least privilege falls out naturally: one role per direction, one registry each
- Two identity systems to understand deeply before it works at all
- A rejected token gives a generic error on the receiving cloud; the detail is on the issuing one
- Token lifetimes cap how long a job may run without re-exchanging
- Container runtimes need a bridge (refresh job or credential provider) on the pull side
What it costs
The setup takes longer than copying a key. The first failed exchange typically costs a day, because a rejected token produces a generic AccessDenied on the receiving cloud while the useful detail, a wrong audience or an issuer with a trailing slash, lives on the issuing one. And it only pays off when both sides name the identity precisely: a role that trusts “any token from Google” is worse than a key file.
Where this applies
Any pair of systems that both issue signed tokens: two public clouds, a cloud and a CI service such as GitHub Actions, a cloud and an on-premises cluster. The resources change names, the three moves do not. Write the trust relationships as infrastructure code. A trust relationship written by hand is a trust relationship nobody can review.