How to Fix Google Cloud 'Permission Denied' 403 Errors (2026)

Nov 10, 2026 · 12 min · Ajay Kumar

This guide is for anyone staring at a Google Cloud PERMISSION_DENIED or HTTP 403 from gcloud, Terraform, a client library or a raw REST call, and wanting to know which of the half-dozen possible causes it is. By the end you will be able to read the missing permission straight out of the error, prove which identity actually made the call, run Policy Troubleshooter against it, and recognise the 403s that have nothing to do with IAM roles: disabled APIs, quota projects, org policies, VPC Service Controls and VM access scopes. Commands match the Google Cloud CLI 592.0.0 reference (released 3 November 2026). You need gcloud installed and a principal that can at least read the project's IAM policy. If you want the concepts behind roles, deny policies and Principal Access Boundary first, read Google Cloud IAM Explained (2026).

Step 1: Read the error for the missing permission and the reason

Most Google Cloud permission errors now tell you exactly what was missing. The permission error messages page documents the shape: the permission, the resource, the authenticating account, an error ID and a Policy Troubleshooter link. From gcloud it looks like this (trimmed from Google's documented example, not a captured run):

ERROR: (gcloud.storage.buckets.list) PERMISSION_DENIED:
... Permission 'storage.buckets.list' denied on resource (or it may not exist). ...
  metadata:
    permission: storage.buckets.list
    error_info_id: ERROR_ID
  reason: IAM_PERMISSION_DENIED

Two words in there matter. The permission is the exact string you need a role to contain. The reason tells you which family of problem you have. The canonical list lives in error_reason.proto in the googleapis repository, and the ones that matter for access errors are:

ErrorInfo reason What it means Go to
IAM_PERMISSION_DENIED No role grants the permission, or a deny or PAB policy blocks it Steps 2 to 4
SERVICE_DISABLED The API is not enabled in the consumer project Step 6
USER_PROJECT_DENIED Caller lacks permission on the quota or billing project Step 6
ACCESS_TOKEN_SCOPE_INSUFFICIENT The OAuth token lacks a required scope Step 8
ACCOUNT_STATE_INVALID The service account behind the token is disabled or deleted Step 2

Note the phrase "or it may not exist". Google returns the same 403 for a resource you cannot see and a resource that is not there, so a typo in a bucket or project ID looks exactly like a missing role. Check the name before you check IAM.

The error ID is newer. Since 15 June 2026 (Preview, per the IAM release notes) you can paste it into Policy Troubleshooter to load the exact principal, resource and permission of the failed request. In the console, the error page also suggests roles, and since 27 February 2026 lets you self-grant the missing permission if you have the right to.

Step 2: Confirm which identity actually made the call

A very common cause of a GCP 403 is not a missing role at all. It is the wrong identity. gcloud and your code use two different credential stores, and the ADC troubleshooting page states it plainly: gcloud commands use the credentials from gcloud auth login, not Application Default Credentials. Your Python script, Terraform and every client library use ADC.

# Who is gcloud using, and is impersonation configured?
gcloud auth list
gcloud config list account project auth/impersonate_service_account

# Is ADC being redirected to a key or federation file?
echo "${GOOGLE_APPLICATION_CREDENTIALS:-<unset>}"

# Which ADC file exists locally (Linux and macOS path)?
ls -l "$HOME/.config/gcloud/application_default_credentials.json"

# On a VM, Cloud Run or GKE node: which service account does the metadata server hand out?
curl -s -H "Metadata-Flavor: Google" \
  http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email

ADC looks in a fixed order, per How Application Default Credentials works: first the file named by GOOGLE_APPLICATION_CREDENTIALS, then the file written by gcloud auth application-default login, then the attached service account from the metadata server. A stale GOOGLE_APPLICATION_CREDENTIALS pointing at an old key from another project beats everything else, including Workload Identity Federation for GKE. If you did not mean to use a file, unset it.

To make ADC use your own user account:

unset GOOGLE_APPLICATION_CREDENTIALS
gcloud auth application-default login

If the reason was ACCOUNT_STATE_INVALID, the service account in the token was disabled or deleted. Re-enable it or attach a different one; no role change will help.

Step 3: Find a role that contains the permission and grant it

Once you know the identity and the permission, check what that identity already has on the project:

PROJECT=my-project
MEMBER="serviceAccount:app@my-project.iam.gserviceaccount.com"

# Roles bound directly to this principal on the project
gcloud projects get-iam-policy "$PROJECT" \
  --flatten="bindings[].members" \
  --filter="bindings.members:$MEMBER" \
  --format="table(bindings.role, bindings.condition.title)"

# Does a candidate role contain the permission?
gcloud iam roles describe roles/storage.objectViewer \
  --format="value(includedPermissions)" | tr ';' '\n' | grep storage.objects.get

This only shows project-level bindings. Roles inherited from folders and the organization, and grants through Google groups, will not appear, which is one reason Step 4 exists. To find which predefined roles include a permission, search the roles and permissions index. Pick the narrowest role and grant it on the narrowest resource:

gcloud storage buckets add-iam-policy-binding gs://my-bucket \
  --member="$MEMBER" \
  --role="roles/storage.objectViewer"

Then wait. Allow and deny policy changes typically take 2 minutes and can take 7 minutes or longer, and group membership changes can take hours, according to access change propagation. Retrying ten seconds after a grant and concluding it failed is a classic.

If the binding has a condition, check it. A condition on request.time that expired last month, or a resource.name prefix that does not match, produces the same 403 as no binding at all.

Step 4: Run Policy Troubleshooter

When the roles look right and the call still fails, ask IAM directly. Policy Troubleshooter evaluates allow policies through the whole hierarchy, group memberships, deny policies and (in the beta track) Principal Access Boundary policies, and tells you which one said no.

# One-time: enable the API in the project you run gcloud against
gcloud services enable policytroubleshooter.googleapis.com

# Allow and deny policies
gcloud policy-intelligence troubleshoot-policy iam \
  //cloudresourcemanager.googleapis.com/projects/my-project \
  --principal-email=app@my-project.iam.gserviceaccount.com \
  --permission=resourcemanager.projects.get

# Include Principal Access Boundary policies (beta track)
gcloud beta policy-intelligence troubleshoot-policy iam \
  //cloudresourcemanager.googleapis.com/projects/my-project \
  --principal-email=app@my-project.iam.gserviceaccount.com \
  --permission=resourcemanager.projects.get

The positional argument is a full resource name, starting with //. --principal-email only accepts Google Accounts and service accounts, per the command reference. For conditional bindings, add --resource-name, --resource-type, --resource-service or --request-time so the tool can evaluate the condition. If results come back Unknown, you lack the roles to see the relevant policies: the troubleshooting docs list Security Reviewer, Deny Reviewer and others on the organization.

Read the output per policy type. A deny rule match means find the deny policy (it can sit on any ancestor) and either add an exception or accept the denial. A PAB "ineligible" result means the principal is bound to a boundary policy that does not list this resource, and no amount of role granting will change that.

Step 5: Fix service account impersonation errors

Impersonation fails with a 403 on iam.serviceAccounts.getAccessToken. The impersonation docs require two things: the caller holds roles/iam.serviceAccountTokenCreator on the target service account (the narrowest grant; a project-level grant also works but covers every service account in the project), and needs it even in a project they created, and the IAM Service Account Credentials API is enabled.

SA=deployer@my-project.iam.gserviceaccount.com

gcloud services enable iamcredentials.googleapis.com --project=my-project

gcloud iam service-accounts add-iam-policy-binding "$SA" \
  --member="user:you@example.com" \
  --role="roles/iam.serviceAccountTokenCreator"

# Per command
gcloud storage buckets list --impersonate-service-account="$SA"

# Or for ADC, so Terraform and client libraries impersonate too
gcloud auth application-default login --impersonate-service-account "$SA"

Remember the second half of the chain: once impersonation works, the request runs as the service account, so the service account itself needs the role on the resource. If you set auth/impersonate_service_account months ago and forgot, every gcloud call is quietly running as that account. gcloud config unset auth/impersonate_service_account turns it off.

Step 6: Fix API not enabled and quota project errors

SERVICE_DISABLED is a 403 that IAM cannot fix. The ErrorInfo names the consumer project and the service, for example "consumer": "projects/123" and "service": "pubsub.googleapis.com", as shown in error_reason.proto. Check that the consumer project is the one you expected, then enable the API there:

gcloud services list --enabled --project=my-project | grep pubsub
gcloud services enable pubsub.googleapis.com --project=my-project

With user credentials in ADC, the consumer project is often not the resource's project but your quota project. The ADC troubleshooting page says client-based APIs need one when you use user credentials, and that the caller needs serviceusage.services.use (in Service Usage Consumer) on it. That is what USER_PROJECT_DENIED means. Set it explicitly:

gcloud auth application-default set-quota-project my-project
gcloud config set billing/quota_project my-project

For raw REST calls, send the x-goog-user-project: my-project header instead. In Terraform, a newly created project can return SERVICE_DISABLED for a short while after the API is enabled, so make dependent resources depend on the google_project_service resource.

Step 7: Recognise org policy and VPC Service Controls denials

If the message talks about an organization's policy, you are not missing a role. VPC Service Controls denials read "Request is prohibited by organization's policy" followed by a vpcServiceControlsUniqueIdentifier, or for Cloud Storage "Request violates VPC Service Controls", per the VPC SC troubleshooting page. Search the protected project's audit logs for that identifier; the violationReason (for example NO_MATCHING_ACCESS_LEVEL or RESOURCES_NOT_IN_SAME_SERVICE_PERIMETER) tells you whether you need an ingress rule, an egress rule or an access level.

Organization policy violations usually name the constraint. The common ones on organizations created since 3 May 2024 are enforced by default: iam.managed.disableServiceAccountKeyCreation blocks key creation and iam.allowedPolicyMemberDomains blocks granting roles to identities outside your domain, which is why adding a vendor's service account fails. Check the effective policy:

gcloud org-policies describe iam.allowedPolicyMemberDomains \
  --project=my-project --effective

The fix is a policy change by someone with Organization Policy Administrator, ideally a narrow project-level override, or a different design (Workload Identity Federation instead of a key). I find it useful to treat these as features: the org policy did its job.

Step 8: Fix VM access scopes (insufficient authentication scopes)

On Compute Engine, the token from the metadata server is limited by the VM's access scopes as well as IAM. The Compute Engine service account docs say both layers must allow the action, and that the default scope set includes read-only Cloud Storage. So a VM whose service account has Storage Object Admin still cannot write objects, and you get ACCESS_TOKEN_SCOPE_INSUFFICIENT. Google recommends the cloud-platform scope with IAM doing the real restriction. Changing scopes requires stopping the VM:

# Inspect current scopes from inside the VM
curl -s -H "Metadata-Flavor: Google" \
  http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/scopes

# From your workstation: stop, change, start
gcloud compute instances stop my-vm --zone=europe-west1-b
gcloud compute instances set-service-account my-vm --zone=europe-west1-b \
  --service-account=app@my-project.iam.gserviceaccount.com \
  --scopes=cloud-platform
gcloud compute instances start my-vm --zone=europe-west1-b
Which kind of 403 is it? Read the 403 reason, permission, principal, error ID IAM_PERMISSION_DENIED missing role, deny or PAB SERVICE_DISABLED or USER_PROJECT_DENIED Token scope too narrow VM access scopes VPC SC or org policy prohibited by policy Policy Troubleshooter, narrow grant or exception gcloud services enable, set the quota project Stop VM, set scope to cloud-platform Ingress or egress rule, or org policy change Before any of these: confirm the identity with gcloud auth list, config list and GOOGLE_APPLICATION_CREDENTIALS.
Triage flow for Google Cloud 403 errors, keyed on the ErrorInfo reasons defined in googleapis error_reason.proto and the VPC Service Controls error text.

Troubleshooting

Symptom Likely cause Fix
Permission 'X' denied on resource (or it may not exist) Missing role, or wrong resource name Check the name, then grant a role containing X (Step 3)
gcloud works, Python or Terraform gets 403 gcloud and ADC use different accounts gcloud auth application-default login; unset GOOGLE_APPLICATION_CREDENTIALS (Step 2)
Role granted, still 403 a minute later Propagation, typically 2 to 7 minutes, groups longer Wait, then retry; do not stack more grants
Role is there, Troubleshooter shows a deny rule Deny policy on an ancestor Add an exceptionPrincipals entry or accept the denial (Step 4)
Troubleshooter (beta) says ineligible Principal Access Boundary policy Add the resource to the PAB policy rules
403 on iam.serviceAccounts.getAccessToken No Token Creator on the service account Grant roles/iam.serviceAccountTokenCreator on the SA (Step 5)
SERVICE_DISABLED API off in the consumer project gcloud services enable in the right project (Step 6)
USER_PROJECT_DENIED or unknown project 764086051850 No or wrong quota project gcloud auth application-default set-quota-project (Step 6)
ACCESS_TOKEN_SCOPE_INSUFFICIENT on a VM Default access scopes Stop VM, set cloud-platform scope (Step 8)
Request is prohibited by organization's policy VPC Service Controls perimeter Ingress or egress rule using the unique ID in audit logs (Step 7)

FAQ

What does "Permission denied on resource (or it may not exist)" mean in Google Cloud?

The caller lacks the named permission on that resource, or the resource does not exist. Google returns the same message for both so that 403s do not leak which resources exist. Check the resource name first, then IAM.

Why does gcloud work but my code gets a 403?

gcloud uses the account from gcloud auth login, while client libraries and Terraform use Application Default Credentials. They can be different accounts, and GOOGLE_APPLICATION_CREDENTIALS overrides ADC entirely. Run gcloud auth application-default login or fix the environment variable.

How long do Google Cloud IAM changes take to apply?

Google's documentation says allow and deny policy changes typically take effect in about 2 minutes and can take 7 minutes or longer. Group membership changes can take hours.

Can an Owner get a 403 in Google Cloud?

Yes. Deny policies, Principal Access Boundary policies, organization policies, VPC Service Controls, disabled APIs and VM access scopes all block requests regardless of the caller's roles.

What I check first, every time, is identity: gcloud auth list, the ADC file and GOOGLE_APPLICATION_CREDENTIALS, because a correct role on the wrong account looks exactly like a missing role. Then the reason field, which sorts the problem into IAM or not-IAM in one glance, and only then Policy Troubleshooter. In production I would also make sure nothing depends on a key file, so that the identity question has one answer per workload; GitHub Actions OIDC covers the CI side. Official references: permission error messages, troubleshoot access, troubleshoot ADC and deny policies. If you are coming from AWS, How to Fix AWS AccessDenied is the equivalent walk-through there.

Related: Google Cloud IAM Explained (2026): New Basic Roles, Deny and PAB, How to Fix AWS AccessDenied and 'Not Authorized to Perform' Errors (2026) and Workload Identity Federation Explained (2026): OIDC Instead of Cloud Keys.

Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related