bitof.dev_
writing

Keyless across clouds: identities instead of key files

2026-09-05 · architecture, identity, multi-cloud, security, terraform

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.

Keyless trust between two cloudsBuild runnerGCP · builds imagesAWS STStrusts Google issuerECRAWS · imagesArtifact RegistryGCP · imagesIdentity FederationGCP · trusts EKS issuerPartner EKSAWS · pulls imagesGoogle ID tokencredentials · 1hprojected SA tokenaccess token
Identity federation in both directions. Nothing in this picture is a file you could leak.

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.

Components on the push sideBuild service accountGCP · has a unique idIAM OIDC providerAWS · accounts.google.comMetadata serverGCP · signs ID tokensIAM roleAWS · trusts that sub onlyECR repositoryAWS · push permissionasks for tokenSTS exchangefederated principalpush only
What you actually create. The trust policy is the whole security boundary.

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.

[±] Trade-offs
  • 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.

> new message