{"id":112,"date":"2026-03-03T12:00:00","date_gmt":"2026-03-03T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=112"},"modified":"2026-10-30T07:05:25","modified_gmt":"2026-10-30T07:05:25","slug":"zero-credentials-in-pods-how-i-implemented-workload-identity-across-aws-gcp-and-azure","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=112","title":{"rendered":"Zero Credentials in Pods. How I Implemented Workload Identity Across AWS, GCP, and Azure"},"content":{"rendered":"<p class=\"wp-block-paragraph\">I built a Multi-Cloud IAM Security Framework across AWS, GCP, and Azure.<\/p><p class=\"wp-block-paragraph\">Then my own audit script found 6 HIGH severity issues in my own accounts.<\/p><p class=\"wp-block-paragraph\">Let me show you everything I built, how it works, and what I found.<\/p><p class=\"wp-block-paragraph\">I wanted to build a project that goes beyond tutorials.<\/p><p class=\"wp-block-paragraph\">Most IAM tutorials show you how to create a role or attach a policy. They stop there. They do not show you how to audit what you built. They do not show you how to connect IAM to Kubernetes workloads. They do not show you how the same concept works differently across AWS, GCP, and Azure.<\/p><p class=\"wp-block-paragraph\">I wanted to build all of that in one project.<\/p><p class=\"wp-block-paragraph\">So I set three goals:<\/p><p class=\"wp-block-paragraph\">Implement least privilege properly across 3 clouds , not just &#8220;create some roles&#8221; but actually scope every permission to the exact resource it needs.<\/p><p class=\"wp-block-paragraph\">Eliminate static credentials from Kubernetes pods , use Workload Identity on EKS, GKE, and AKS so pods never store credentials.<\/p><p class=\"wp-block-paragraph\">Automate the auditing , write Python scripts that scan for real security issues and run daily in CI\/CD.<\/p><p class=\"wp-block-paragraph\">I thought when I finished, my accounts would be clean.<\/p><p class=\"wp-block-paragraph\">The audit script found 6 HIGH severity findings on the first run.<\/p><p class=\"wp-block-paragraph\">In accounts I built myself.<\/p><p class=\"wp-block-paragraph\">That moment taught me more about IAM security than any course.<\/p><h3 class=\"wp-block-heading\">What is this project?<\/h3><p class=\"wp-block-paragraph\">A complete IAM least-privilege framework built across 3 clouds , all managed with Terraform, no manual console changes.<\/p><p class=\"wp-block-paragraph\">Here is what I built:<\/p><p class=\"wp-block-paragraph\">AWS:<\/p><ul class=\"wp-block-list\"><li>IAM groups (dev, ops, readonly, admin) , policies attached to groups, never directly to users<\/li><li>4 custom managed policies , each scoped to exact resources needed<\/li><li>5 IAM roles , app, cicd, lambda, readonly, db-admin<\/li><li>IRSA on EKS , pods access AWS without credentials<\/li><li>Cross-account access patterns<\/li><\/ul><p class=\"wp-block-paragraph\">GCP:<\/p><ul class=\"wp-block-list\"><li>3 service accounts , app, cicd, monitoring<\/li><li>Custom role with only 4 permissions instead of predefined roles\/editor<\/li><li>Workload Identity on GKE , same concept as IRSA<\/li><\/ul><p class=\"wp-block-paragraph\">Azure:<\/p><ul class=\"wp-block-list\"><li>User-assigned Managed Identities , app and monitoring<\/li><li>Custom RBAC roles , scoped to specific resource types<\/li><li>AKS Workload Identity with federated credentials<\/li><li>Key Vault, Storage Account, ACR with least-privilege assignments<\/li><\/ul><p class=\"wp-block-paragraph\">Audit automation:<\/p><ul class=\"wp-block-list\"><li>Python scripts scanning all 3 clouds for security issues<\/li><li>Finds wildcard policies, missing MFA, unused roles, old access keys, exported GCP keys, Azure Owner assignments<\/li><li>Scheduled as daily GitHub Actions cron job<\/li><li>Exits with code 1 on HIGH findings so CI\/CD fails and notifies you<\/li><\/ul><h3 class=\"wp-block-heading\">Why IAM Groups Instead of Direct Policy Attachment?<\/h3><p class=\"wp-block-paragraph\">This is the first rule I implemented.<\/p><p class=\"wp-block-paragraph\">Wrong way , attach policy directly to user:<\/p><pre class=\"wp-block-code\"><code>User: reza \u2192 Policy: app-policy\nUser: ali  \u2192 Policy: app-policy\nUser: sara \u2192 Policy: app-policy<\/code><\/pre><p class=\"wp-block-paragraph\">Now you have 10 developers and 10 places to update when the policy changes.<\/p><p class=\"wp-block-paragraph\">Right way , groups:<\/p><pre class=\"wp-block-code\"><code>Group: dev-team \u2192 Policy: app-policy\n  \u2514\u2500\u2500 User: reza\n  \u2514\u2500\u2500 User: ali\n  \u2514\u2500\u2500 User: sara<\/code><\/pre><p class=\"wp-block-paragraph\">When the policy needs updating, you update one group. All users inside it get the change automatically.<\/p><p class=\"wp-block-paragraph\">In Terraform:<\/p><pre class=\"wp-block-code\"><code>resource \"aws_iam_group\" \"dev\" {\n  name = \"${var.environment}-dev-team\"\n  path = \"\/teams\/\"\n}\n\nresource \"aws_iam_group_policy_attachment\" \"dev_app_policy\" {\n  group      = aws_iam_group.dev.name\n  policy_arn = aws_iam_policy.app_policy.arn\n}\n\nresource \"aws_iam_user_group_membership\" \"dev_user_groups\" {\n  user   = aws_iam_user.dev_user.name\n  groups = [aws_iam_group.dev.name]\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Simple. Maintainable. Scalable.<\/p><h3 class=\"wp-block-heading\">What Least Privilege Actually Looks Like in Code<\/h3><p class=\"wp-block-paragraph\">I see wildcard policies everywhere. In tutorials, in auto-generated policies, in &#8220;quick start&#8221; guides:<\/p><pre class=\"wp-block-code\"><code>{\n  \"Effect\": \"Allow\",\n  \"Action\": \"*\",\n  \"Resource\": \"*\"\n}<\/code><\/pre><p class=\"wp-block-paragraph\">This is full admin access. It allows everything on everything.<\/p><p class=\"wp-block-paragraph\">Here is what least privilege looks like for an application that needs to read from S3 and read secrets:<\/p><pre class=\"wp-block-code\"><code>policy = jsonencode({\n  Version = \"2012-10-17\"\n  Statement = [\n    {\n      Sid    = \"ReadAppS3Bucket\"\n      Effect = \"Allow\"\n      Action = [\n        \"s3:GetObject\",\n        \"s3:ListBucket\",\n        \"s3:GetObjectVersion\"\n      ]\n      # Scoped to ONE specific bucket , cannot read other buckets\n      Resource = [\n        \"arn:aws:s3:::${var.app_s3_bucket_name}\",\n        \"arn:aws:s3:::${var.app_s3_bucket_name}\/*\"\n      ]\n    },\n    {\n      Sid    = \"ReadSecretsManager\"\n      Effect = \"Allow\"\n      Action = [\n        \"secretsmanager:GetSecretValue\",\n        \"secretsmanager:DescribeSecret\"\n      ]\n      # Scoped to secrets with \"dev\/app\/\" prefix only\n      Resource = \"arn:aws:secretsmanager:${var.aws_region}:${var.account_id}:secret:${var.environment}\/app\/*\"\n    }\n  ]\n})<\/code><\/pre><p class=\"wp-block-paragraph\">The app can only read one specific S3 bucket. It can only read secrets with the dev\/app\/ prefix. Nothing else.<\/p><p class=\"wp-block-paragraph\">If this application is compromised, the attacker gets read access to one bucket and specific secrets. Not your entire AWS account.<\/p><p class=\"wp-block-paragraph\">Same principle on GCP. Instead of roles\/editor which has thousands of permissions:<\/p><pre class=\"wp-block-code\"><code>resource \"google_project_iam_custom_role\" \"app_custom_role\" {\n  role_id     = \"${var.environment}_app_custom_role\"\n  title       = \"App Custom Role\"\n  description = \"Minimal permissions for app workload - only what is needed\"\n  project     = var.project_id\n\n  permissions = [\n    \"storage.objects.get\",\n    \"storage.objects.list\",\n    \"pubsub.topics.publish\",\n    \"secretmanager.versions.access\",\n  ]\n}<\/code><\/pre><p class=\"wp-block-paragraph\">4 permissions. That is all the app needs. Nothing more.<\/p><p class=\"wp-block-paragraph\">And Azure:<\/p><pre class=\"wp-block-code\"><code>resource \"azurerm_role_definition\" \"readonly_custom\" {\n  name        = \"${var.environment}-readonly-custom-role\"\n  scope       = \"\/subscriptions\/${var.subscription_id}\"\n  description = \"Read-only access scoped to specific resource types only\"\n\n  permissions {\n    actions = [\n      \"Microsoft.Compute\/virtualMachines\/read\",\n      \"Microsoft.ContainerService\/managedClusters\/read\",\n      \"Microsoft.Storage\/storageAccounts\/read\",\n      \"Microsoft.KeyVault\/vaults\/read\",\n    ]\n    not_actions = []\n  }\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Not the built-in Reader role. A custom role with exactly the resource types we need to read.<\/p><h3 class=\"wp-block-heading\">The Most Important Concept: Workload Identity<\/h3><p class=\"wp-block-paragraph\">Before this project, when I needed a Kubernetes pod to access AWS, I did this:<\/p><pre class=\"wp-block-code\"><code>env:\n  - name: AWS_ACCESS_KEY_ID\n    valueFrom:\n      secretKeyRef:\n        name: aws-credentials\n        key: access-key-id\n  - name: AWS_SECRET_ACCESS_KEY\n    valueFrom:\n      secretKeyRef:\n        name: aws-credentials\n        key: secret-access-key<\/code><\/pre><p class=\"wp-block-paragraph\">Static credentials. Stored in Kubernetes secrets. Those secrets live in etcd. In many clusters they are not encrypted at rest. Anyone with access to the cluster can read them.<\/p><p class=\"wp-block-paragraph\">This is how breaches happen.<\/p><p class=\"wp-block-paragraph\">After this project, the pod accesses AWS like this:<\/p><pre class=\"wp-block-code\"><code>apiVersion: v1\nkind: ServiceAccount\nmetadata:\n  name: app-sa\n  namespace: app\n  annotations:\n    eks.amazonaws.com\/role-arn: arn:aws:iam::*****:role\/roles\/dev-app-irsa-role\n---\napiVersion: v1\nkind: Pod\nmetadata:\n  name: app-pod\n  namespace: app\nspec:\n  serviceAccountName: app-sa\n  containers:\n    - name: app\n      image: my-app:latest\n      # No AWS credentials anywhere , not even in environment variables<\/code><\/pre><p class=\"wp-block-paragraph\">No credentials. Anywhere.<\/p><p class=\"wp-block-paragraph\">When I exec into the pod and run:<\/p><pre class=\"wp-block-code\"><code>aws sts get-caller-identity<\/code><\/pre><p class=\"wp-block-paragraph\">I get:<\/p><pre class=\"wp-block-code\"><code>{\n    \"UserId\": \"******:botocore-session-****\",\n    \"Account\": \"*****\",\n    \"Arn\": \"arn:aws:sts::*****:assumed-role\/dev-app-irsa-role\/botocore-session-****\"\n}<\/code><\/pre><p class=\"wp-block-paragraph\">The IAM role ARN. Not my personal credentials. The pod has exactly the permissions the role allows and nothing more. The credentials are short-lived , they expire and rotate automatically.<\/p><p class=\"wp-block-paragraph\">I implemented Workload Identity on all 3 clouds:<\/p><p class=\"wp-block-paragraph\">AWS EKS \u2192 IRSA (IAM Roles for Service Accounts) How it works: EKS cluster has an OIDC provider. IAM role has a trust policy that says &#8220;only this specific Kubernetes ServiceAccount in this specific namespace can assume me.&#8221; Pod gets a projected token that AWS validates against the OIDC provider.<\/p><p class=\"wp-block-paragraph\">GCP GKE \u2192 Workload Identity How it works: GCP SA has an IAM binding: &#8220;this K8s ServiceAccount can impersonate me.&#8221; K8s SA is annotated with the GCP SA email. Pod gets GCP credentials automatically.<\/p><p class=\"wp-block-paragraph\">Azure AKS \u2192 Federated Credentials How it works: Managed Identity has a federated credential: &#8220;trust tokens from this AKS cluster for this K8s ServiceAccount.&#8221; Pod gets AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_FEDERATED_TOKEN_FILE injected automatically by the Workload Identity webhook.<\/p><p class=\"wp-block-paragraph\">Same result on all 3 clouds. Zero static credentials anywhere.<\/p><h3 class=\"wp-block-heading\">How IRSA Works Under the Hood<\/h3><p class=\"wp-block-paragraph\">This is the part most tutorials skip. Here is exactly what happens:<\/p><p class=\"wp-block-paragraph\">Step 1 , Register the OIDC provider in IAM:<\/p><pre class=\"wp-block-code\"><code>data \"tls_certificate\" \"eks_oidc\" {\n  url = aws_eks_cluster.main.identity[0].oidc[0].issuer\n}\n\nresource \"aws_iam_openid_connect_provider\" \"eks\" {\n  client_id_list  = [\"sts.amazonaws.com\"]\n  thumbprint_list = [data.tls_certificate.eks_oidc.certificates[0].sha1_fingerprint]\n  url             = aws_eks_cluster.main.identity[0].oidc[0].issuer\n}<\/code><\/pre><p class=\"wp-block-paragraph\">Step 2 , Create the role with a trust policy scoped to ONE ServiceAccount:<\/p><pre class=\"wp-block-code\"><code>assume_role_policy = jsonencode({\n  Version = \"2012-10-17\"\n  Statement = [{\n    Effect = \"Allow\"\n    Principal = {\n      Federated = aws_iam_openid_connect_provider.eks.arn\n    }\n    Action = \"sts:AssumeRoleWithWebIdentity\"\n    Condition = {\n      StringEquals = {\n        # Only this exact ServiceAccount in this exact namespace\n        \"${local.oidc_issuer_host}:sub\" = \"system:serviceaccount:app:app-sa\"\n        \"${local.oidc_issuer_host}:aud\" = \"sts.amazonaws.com\"\n      }\n    }\n  }]\n})<\/code><\/pre><p class=\"wp-block-paragraph\">Even if another pod in the same cluster tries to assume this role, it will be denied. The condition is that strict.<\/p><p class=\"wp-block-paragraph\">Step 3 , Annotate the Kubernetes ServiceAccount:<\/p><pre class=\"wp-block-code\"><code>annotations:\n  eks.amazonaws.com\/role-arn: arn:aws:iam::*****:role\/roles\/dev-app-irsa-role<\/code><\/pre><p class=\"wp-block-paragraph\">When the pod starts, EKS injects these environment variables automatically:<\/p><pre class=\"wp-block-code\"><code>AWS_ROLE_ARN=arn:aws:iam::*****:role\/roles\/dev-app-irsa-role\nAWS_WEB_IDENTITY_TOKEN_FILE=\/var\/run\/secrets\/eks.amazonaws.com\/serviceaccount\/token\nAWS_DEFAULT_REGION=us-east-1<\/code><\/pre><p class=\"wp-block-paragraph\">The AWS SDK reads these automatically. No code changes needed in your application.<\/p><h3 class=\"wp-block-heading\">The Audit Script<\/h3><p class=\"wp-block-paragraph\">After building the framework I wrote Python scripts to scan all 3 clouds automatically.<\/p><p class=\"wp-block-paragraph\">AWS audit , 4 checks:<\/p><pre class=\"wp-block-code\"><code>def check_wildcard_policies(iam, report):\n    paginator = iam.get_paginator(\"list_policies\")\n    for page in paginator.paginate(Scope=\"Local\"):\n        for policy in page[\"Policies\"]:\n            version = iam.get_policy_version(\n                PolicyArn=policy[\"Arn\"],\n                VersionId=policy[\"DefaultVersionId\"]\n            )\n            statements = version[\"PolicyVersion\"][\"Document\"].get(\"Statement\", [])\n            for stmt in statements:\n                if stmt.get(\"Effect\") != \"Allow\":\n                    continue\n                actions = stmt.get(\"Action\", [])\n                if isinstance(actions, str):\n                    actions = [actions]\n                if \"*\" in actions:\n                    report.add(\"HIGH\", \"Wildcard Policy\", policy[\"PolicyName\"],\n                               \"Policy allows Action: '*' , grants ALL AWS permissions\")\n\ndef check_users_without_mfa(iam, report):\n    # Only flag users with console access (LoginProfile)\n    # No console access = no MFA needed\n    ...\n\ndef check_unused_roles(iam, report):\n    # Uses RoleLastUsed from AWS , no extra API calls\n    # Skips AWS service-linked roles automatically\n    ...\n\ndef check_old_access_keys(iam, report):\n    # Access keys older than 90 days = MEDIUM finding\n    # Gives key ID prefix so you know which key to rotate\n    ...<\/code><\/pre><p class=\"wp-block-paragraph\">The script exits with code 1 if any HIGH findings exist. The GitHub Actions daily cron job fails and sends a notification. No manual checking needed.<\/p><h3 class=\"wp-block-heading\">What the Audit Found in My Own Accounts<\/h3><p class=\"wp-block-paragraph\">I ran the script against my real accounts. Here are the actual results:<\/p><p class=\"wp-block-paragraph\">AWS , 34 total findings, 4 HIGH:<\/p><p class=\"wp-block-paragraph\">[HIGH] Wildcard Policy<br>Resource : AWSCodePipelineServiceRole-us-east-1-myfirst-pipeline<br>Detail : Policy allows Action: &#8216;*&#8217; on Resource: &#8216;*&#8217; , full admin access<\/p><p class=\"wp-block-paragraph\">[HIGH] Wildcard Policy<br>Resource : AWSCodePipelineServiceRole-us-east-1-my-iacdevops-pipe1<br>Detail : Policy allows Action: &#8216;*&#8217; on Resource: &#8216;*&#8217; , full admin access<\/p><p class=\"wp-block-paragraph\">[HIGH] No MFA<br>Resource : admin<br>Detail : User has console access but NO MFA device configured<\/p><p class=\"wp-block-paragraph\">[HIGH] No MFA<br>Resource : reza<br>Detail : User has console access but NO MFA device configured<\/p><p class=\"wp-block-paragraph\">Also found 13 MEDIUM findings (unused roles, old access keys) and 17 LOW findings (roles last used 400-630 days ago).<\/p><p class=\"wp-block-paragraph\">GCP , 3 total findings, 1 HIGH:<\/p><p class=\"wp-block-paragraph\">[HIGH] Primitive Role<br>Resource : 589443437117-compute@developer.gserviceaccount.com<br>Detail : Member has &#8216;roles\/editor&#8217; , grants too broad access.<br>Use predefined or custom roles instead.<\/p><p class=\"wp-block-paragraph\">Azure , 1 finding, 1 HIGH:<\/p><p class=\"wp-block-paragraph\">[HIGH] Owner Role Assignment<br>Resource : *****.com#EXT#<br>Detail : User has Owner role at subscription level.<br>Owner can do everything including change IAM.<\/p><p class=\"wp-block-paragraph\">Total: 6 HIGH severity findings. In accounts I thought were secure.<\/p><h3 class=\"wp-block-heading\">The Most Interesting Finding<\/h3><p class=\"wp-block-paragraph\">Both CodePipeline wildcard policies were created by AWS automatically. Not by me.<\/p><p class=\"wp-block-paragraph\">I built pipelines last year. AWS created the IAM roles and policies automatically during setup. With Action: &#8216;*&#8217; on Resource: &#8216;*&#8217;. Full admin access. Auto-generated by AWS.<\/p><p class=\"wp-block-paragraph\">My audit script caught what AWS itself created.<\/p><p class=\"wp-block-paragraph\">This is why automated auditing matters. Even AWS-generated IAM needs to be reviewed.<\/p><p class=\"wp-block-paragraph\">The GCP finding is similar. Every new GCP project automatically gives the default compute service account roles\/editor. Google creates an overprivileged identity by default. My script flagged it without me specifically looking for it.<\/p><h3 class=\"wp-block-heading\">Differences Between the 3 Clouds<\/h3><p class=\"wp-block-paragraph\">This surprised me during the project.<\/p><p class=\"wp-block-paragraph\">AWS starts strict. No permissions unless you explicitly grant them. If you create an EC2 instance with no instance profile, it has zero AWS permissions. You must explicitly grant everything.<\/p><p class=\"wp-block-paragraph\">GCP starts permissive. The default compute service account gets roles\/editor on every new project automatically. You have to actively go and remove it. This is a well-known GCP bad default that my audit found immediately.<\/p><p class=\"wp-block-paragraph\">Azure depends on your subscription setup. Your personal account gets Owner on your own subscription. In a company environment this would be tightly controlled. In a personal learning account, you are the owner of everything.<\/p><p class=\"wp-block-paragraph\">Same goal (least privilege), different starting points, different things to watch out for on each cloud.<\/p><p class=\"wp-block-paragraph\">How IAM works differently:<\/p><ul class=\"wp-block-list\"><li>AWS: identity-based policies AND resource-based policies , two separate places where permissions can be granted<\/li><li>GCP: everything is a binding , member + role + resource. One consistent pattern.<\/li><li>Azure: RBAC assignments at scope , similar to GCP but with Azure AD layered on top. The scope hierarchy (resource \u2192 resource group \u2192 subscription \u2192 management group) controls inheritance.<\/li><\/ul><h3 class=\"wp-block-heading\">My Mistakes During This Project<\/h3><p class=\"wp-block-paragraph\">I made mistakes on every cloud. Here are the 5 that cost me the most time.<\/p><h3 class=\"wp-block-heading\">Mistake 1: Wrong IAM role ARN path<\/h3><p class=\"wp-block-paragraph\">The error:<\/p><pre class=\"wp-block-code\"><code>An error occurred (NoSuchEntity) when calling the AssumeRole operation:\nRole arn:aws:iam::*****:role\/dev-app-irsa-role does not exist<\/code><\/pre><p class=\"wp-block-paragraph\">My IAM role was created with a path:<\/p><pre class=\"wp-block-code\"><code>path = \"\/roles\/\"<\/code><\/pre><p class=\"wp-block-paragraph\">The actual ARN includes the path:<\/p><pre class=\"wp-block-code\"><code>arn:aws:iam::*****:role\/roles\/dev-app-irsa-role<\/code><\/pre><p class=\"wp-block-paragraph\">But I was referencing the role without the path:<\/p><pre class=\"wp-block-code\"><code>arn:aws:iam::*****:role\/dev-app-irsa-role<\/code><\/pre><p class=\"wp-block-paragraph\">The fix , use the ARN directly from Terraform, not hardcoded:<\/p><pre class=\"wp-block-code\"><code>annotations:\n  eks.amazonaws.com\/role-arn: aws_iam_role.app_role.arn<\/code><\/pre><h3 class=\"wp-block-heading\">Mistake 2: OIDC thumbprint mismatch<\/h3><p class=\"wp-block-paragraph\">The error:<\/p><pre class=\"wp-block-code\"><code>InvalidIdentityToken: OpenIDConnect provider's HTTPS certificate doesn't match configured thumbprint<\/code><\/pre><p class=\"wp-block-paragraph\">The fix , use data.tls_certificate to fetch the actual thumbprint dynamically:<\/p><pre class=\"wp-block-code\"><code>data \"tls_certificate\" \"eks_oidc\" {\n  url = aws_eks_cluster.main.identity[0].oidc[0].issuer\n}\n\nthumbprint_list = [data.tls_certificate.eks_oidc.certificates[0].sha1_fingerprint]<\/code><\/pre><p class=\"wp-block-paragraph\">The thumbprint is the SHA1 fingerprint of the OIDC provider&#8217;s root CA certificate. It rotates when the certificate rotates. Never hardcode it.<\/p><h3 class=\"wp-block-heading\">Mistake 3: OIDC ID mismatch between cluster and role<\/h3><p class=\"wp-block-paragraph\">The role trusted OIDC provider:<\/p><pre class=\"wp-block-code\"><code>oidc.eks.us-east-1.amazonaws.com\/id\/****<\/code><\/pre><p class=\"wp-block-paragraph\">But the pod tokens came from:<\/p><pre class=\"wp-block-code\"><code>oidc.eks.us-east-1.amazonaws.com\/id\/*****<\/code><\/pre><p class=\"wp-block-paragraph\">Different OIDC provider IDs. The trust policy failed silently. The pod could not assume the role.<\/p><p class=\"wp-block-paragraph\">Fix: decode the pod token to verify the issuer matches what the role trusts:<\/p><pre class=\"wp-block-code\"><code>kubectl exec -n app app-pod -- cat \/var\/run\/secrets\/eks.amazonaws.com\/serviceaccount\/token | cut -d'.' -f2 | base64 -d | jq .iss<\/code><\/pre><p class=\"wp-block-paragraph\">Output:<\/p><pre class=\"wp-block-code\"><code>\"https:\/\/oidc.eks.us-east-1.amazonaws.com\/id\/******\"<\/code><\/pre><p class=\"wp-block-paragraph\">Match this against your role&#8217;s trust policy Condition.<\/p><h3 class=\"wp-block-heading\">Mistake 4: Azure token path in verify script<\/h3><p class=\"wp-block-paragraph\">My verify script tried to read the Azure token from \/var\/run\/secrets\/azure\/tokens\/azure-identity-token<\/p><p class=\"wp-block-paragraph\">The actual file is \/var\/run\/secrets\/azure\/tokens\/azure-identity-token.txt<\/p><p class=\"wp-block-paragraph\">Notice the .txt extension. Without it the script said &#8220;token file not found&#8221; even though Workload Identity was working perfectly.<\/p><p class=\"wp-block-paragraph\">Fix: check the actual mount path in the pod, don&#8217;t assume:<\/p><pre class=\"wp-block-code\"><code>kubectl exec -n app app-pod -- ls -la \/var\/run\/secrets\/azure\/tokens\/<\/code><\/pre><h3 class=\"wp-block-heading\">Mistake 5: Forgotten em dashes in Terraform descriptions<\/h3><p class=\"wp-block-paragraph\">Terraform descriptions are ASCII-only. The validation regex is:<\/p><pre class=\"wp-block-code\"><code>[\\u0009\\u000A\\u000D\\u0020-\\u007E\\u00A1-\\u00FF]*<\/code><\/pre><p class=\"wp-block-paragraph\">Em dashes (\u2014) are U+2014 , outside this range. Terraform apply failed with:<\/p><pre class=\"wp-block-code\"><code>Error: invalid description: contains non-ASCII characters<\/code><\/pre><p class=\"wp-block-paragraph\">Fix: replace \u2014 with &#8211; everywhere.<\/p><p class=\"wp-block-paragraph\">Why did this happen? Because my descriptions had em dashes (\u2014) from LLM-generated text. That is why you should always write your own descriptions and avoid copying AI text directly into Terraform.<\/p><h3 class=\"wp-block-heading\">What I Learned<\/h3><ol class=\"wp-block-list\"><li>Never trust auto-generated IAM AWS, GCP, and Azure all create IAM resources automatically. These auto-generated identities are often overprivileged. Audit them regularly. Auto-generated does not mean secure.<\/li><li>Least privilege is a discipline, not a configuration You have to actively decide every permission. Default to deny, then grant the minimum. The moment you get lazy and use &#8220;*&#8221; everything breaks.<\/li><li>Workload Identity eliminates the biggest credential risk If a pod does not store credentials, those credentials cannot leak. The attack surface becomes the IAM role permissions, not the pod itself. This is a massive security improvement.<\/li><li>Automated auditing is not optional Manual audits don&#8217;t scale. You have 5 AWS accounts, 3 GCP projects, and 2 Azure subscriptions? You need scripts. Run them daily. Exit with code 1 on HIGH findings.<\/li><li>Cloud IAM is the same concept everywhere, different syntax The idea is always &#8220;who can do what to which resource.&#8221; But the implementation details (OIDC providers, federated credentials, IAM bindings, RBAC assignments) are different. Learn the concept once, then learn the syntax per cloud.<\/li><\/ol><p class=\"wp-block-paragraph\">Github: <a href=\"https:\/\/github.com\/mohammadrezachegini\/iam-least-privilege-project\">https:\/\/github.com\/mohammadrezachegini\/iam-least-privilege-project<\/a><\/p>","protected":false},"excerpt":{"rendered":"<p>I built a Multi-Cloud IAM Security Framework across AWS, GCP, and Azure. Then my own audit script found 6 HIGH severity issues&hellip;<\/p>\n","protected":false},"author":2,"featured_media":114,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/zero-credentials-pods-how-i-implemented-workload-identity-chegini-meice\/","footnotes":""},"categories":[50,10,9],"tags":[60,52,8,61,3,38,59],"series":[],"class_list":["post-112","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-azure","category-devops","category-kubernetes","tag-aws","tag-azure","tag-devops","tag-gcp","tag-kubernetes","tag-security","tag-workload-identity"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/10\/zero-credentials-in-pods-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/112","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=112"}],"version-history":[{"count":1,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/112\/revisions"}],"predecessor-version":[{"id":113,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/112\/revisions\/113"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/114"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=112"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=112"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=112"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=112"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}