Azure Troubleshooting

Terraform AzureRM Provider Auth Failures: 401/403, Stale Tokens & SPN Permission Fixes

You wrote your first few resources, you ran terraform plan, and instead of a tidy list of things to create you got a wall of red: Error: building AzureRM Client followed by an AADSTS code and a hyperlink. Or worse — plan worked fine yesterday, you changed nothing, and today every command fails with 401 or 403. This is the single most common place people get stuck with Terraform on Azure, and it is maddening because the error rarely points at the real cause. The azurerm provider is the plugin Terraform uses to talk to Azure, and before it can create a single resource it must do two separate things: prove who it is to Microsoft Entra ID (authentication — get a token), then prove it is allowed to do the operation (authorization — pass an Azure RBAC check). A 401 is the first one failing; a 403 is the second. Telling them apart in the first thirty seconds is most of the battle.

This is that playbook. We treat the whole class of “Terraform can’t authenticate to Azure” not as one bug but as a small family of distinct failures — wrong credentials, expired secret, stale cached token, wrong tenant, wrong subscription, and the service principal simply lacking a role — each with a precise way to confirm it and a precise fix. You will learn to read the AADSTS error numbers (they are specific and honest once you know them), to drive az account and az login to clear and reset your session, and to grant a service principal the minimum RBAC role it needs with one az role assignment create. Every fix comes with the exact az command to run and the exact error string that tells you you were right. Because you will hit this mid-frustration, the centrepiece is a big symptom-to-fix table you can keep open while everything is on fire.

By the end you will stop guessing. When terraform plan goes red you will know within a minute whether you are logged in as the wrong identity, holding an expired secret, pointed at someone else’s tenant, or missing Contributor on the subscription — and you will know the one command that fixes each. That difference is what turns a lost afternoon into a two-minute correction.

What problem this solves

Terraform’s promise is that you describe infrastructure in code and apply makes it real — none of which happens until the provider authenticates. Authentication is the gate every Terraform-on-Azure user hits on day one, and where most stall: not because the concept is hard, but because the errors mix two different failures (identity and permission) and surface raw Entra codes that mean nothing without a decoder ring.

What breaks without this knowledge: you paste a client secret into the wrong field, set an environment variable that silently overrides your CLI login, or — the classic — give the service principal Owner on the whole subscription just to make the red go away, opening a security hole you never revisit. Meanwhile the real cause is small and specific: a secret that expired last night, a token cached from a different subscription, a tenant ID copied from the wrong place, or a missing role assignment one command would fix.

Who hits this: everyone running Terraform against Azure, but hardest on people moving from “it works on my laptop with az login” to “it must work in a CI/CD pipeline with a service principal” — that move swaps the entire authentication mechanism. It also bites anyone with multiple Azure tenants or subscriptions (consultants, a personal and a work tenant), where pointing at the wrong one produces a 403 that looks like a permission bug but is really a “you’re in the wrong directory” bug.

To frame the whole field before the deep dive, here is every failure class this article covers, the question it forces, and the first place to look.

Failure class What is really failing First question to ask First command to run
401 / invalid_client Authentication — credentials are wrong or expired Is the secret/cert right and not expired? az ad sp credential reset (after confirming the appId)
403 / AuthorizationFailed Authorization — identity has no RBAC role Does this identity have a role on the scope? az role assignment list --assignee <id>
Stale token A cached token from a different context Did this work before I switched accounts/subs? az account clear then az login
Wrong tenant Authenticating against the wrong directory Are these resources in this tenant? az account show --query tenantId
Wrong subscription Right tenant, wrong subscription selected Is the right subscription the active one? az account show --query name
No subscriptions found SPN can authenticate but sees nothing Does the SPN have any role anywhere? az role assignment list --assignee <id> --all

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should have the Azure CLI (az) installed and be able to az login, and Terraform installed (terraform version). You need an Azure subscription and the ability to create a service principal in your tenant (or someone who can). You should know what a Terraform provider block is and have a minimal config with the azurerm provider declared. Familiarity with RBAC roles (Reader, Contributor, Owner) helps, but we’ll define them.

This sits at the very start of the Terraform-on-Azure journey — the gate before everything else. It comes right before Terraform on Azure: Remote State in Blob Storage with State Locking & Workspaces, because the backend authenticates the same way (fix auth here, it’s fixed there too). The identity you create is an Entra ID app registration plus service principal, covered in depth in Entra App Registration: OIDC, Confidential Clients & Federated Credentials. For the keyless approach that removes secrets entirely, Entra Managed Identities Deep Dive: User-Assigned, FIC & RBAC is where you go next.

A quick map of who owns which layer, so you escalate to the right person fast.

Layer What lives here Who usually owns it Failure it causes
Local machine az CLI session, env vars, cached tokens You / the developer Stale token, wrong sub, env var override
App registration (Entra ID) The appId, secrets, certificates Identity / Entra admin 401 invalid_client, expired secret
Service principal (Entra ID) The directory object that gets roles Identity / Entra admin 403 if it exists but has no role
RBAC assignment Role + scope binding the SPN to resources Subscription owner 403 AuthorizationFailed
Subscription / tenant The directory and billing boundary Cloud platform team Wrong tenant, no subscriptions found

Core concepts

Four mental models make every later diagnosis obvious.

Authentication and authorization are two separate gates, and the error code tells you which one slammed. First, the provider gets an access token from Microsoft Entra ID by proving its identity — authentication; failure gives a 401 carrying an AADSTS code. Second, with a token in hand, every API call is checked against Azure RBAC for whether that identity may do this operation — authorization; failure gives a 403 with AuthorizationFailed. A 401 means “I don’t know who you are”; a 403 means “I know exactly who you are, and you’re not allowed.” Never apply an authorization fix (granting a role) to an authentication problem (bad secret).

The azurerm provider has several ways to authenticate, and you pick exactly one. Locally, the easiest is Azure CLI authentication — run az login once and the provider borrows your CLI token, no secrets in code. For automation you use a service principal (a non-human identity) with either a client secret (a password — simplest, but expires and must be stored) or a client certificate (stronger, also expires). The modern best practice for CI/CD is OIDC / workload identity federation (a short-lived token, no stored secret) or, when Terraform runs on Azure compute, a managed identity. You choose by setting the right provider arguments or environment variables; set several at once and you get confusing conflicts.

A service principal is a non-human login, and it needs both an identity and a role. “Creating a service principal” actually creates two linked Entra objects: an app registration (the application definition, holding the appId / client ID and any secrets) and a service principal (the instance of that app in your tenant, the thing RBAC roles attach to). That gives you a login that can authenticate — but it does nothing until you also grant it a role assignment (a role + a scope). Terraform users routinely create the SPN, configure the provider perfectly, and still get 403: they forgot the role. Authentication succeeded; authorization had nothing to pass.

Your session has a tenant and a subscription, and Terraform inherits both unless told otherwise. Entra ID groups resources into tenants (directories — your personal Azure account and your employer’s are different tenants), and a tenant contains subscriptions (billing and resource boundaries). The az CLI remembers a current tenant and subscription, and the provider quietly uses whatever those are. With access to multiple tenants or subscriptions, the active one may not match your .tf files — producing a 403 or “resource not found” that looks like a bug but is really a wrong-context problem. Set tenant and subscription explicitly to remove the ambiguity.

The vocabulary in one table

Before the deep sections, pin down every moving part.

Concept One-line definition Where it lives Why it matters to 401/403
Tenant (directory) An Entra ID instance; the identity boundary Entra ID Wrong tenant → can’t see your resources
Subscription A billing + resource container in a tenant Under a tenant Wrong sub → 403 / not found
App registration The application definition (holds client ID + secrets) Entra ID Bad secret here → 401 invalid_client
Service principal The tenant-local instance roles attach to Entra ID Exists but no role → 403
Client ID (appId) Public identifier of the app/SPN App registration Wrong value → 401 invalid_client
Client secret A password for the SPN; expires App registration Expired/wrong → 401
Tenant ID GUID of the directory to authenticate against Entra ID Wrong value → 401 / wrong directory
RBAC role A set of allowed actions (Reader/Contributor/Owner) Azure RBAC None assigned → 403 AuthorizationFailed
Scope Where a role applies (sub / RG / resource) Azure RBAC Role at wrong scope → 403 on the gap
Access token The short-lived proof of identity Issued by Entra Stale/cached wrong one → confusing errors

The AADSTS error-code reference

The single most useful skill here is reading the AADSTS code Entra returns — these numbers are specific and consistent, and once you recognise the common ones you diagnose 401s on sight. Here are the codes you realistically hit with the azurerm provider, what each means, and the first fix. (AADSTS = Azure Active Directory Security Token Service, the old name for the Entra token endpoint; the prefix stuck.)

AADSTS code Meaning Likely cause with Terraform First fix
AADSTS7000215 Invalid client secret provided Wrong secret value, or you pasted the secret ID not the value Reset the secret; use the value shown once at creation
AADSTS7000222 The client secret has expired Secret passed its expiry date Create a new secret; update ARM_CLIENT_SECRET
AADSTS700016 Application not found in the directory Wrong client ID, or SPN is in a different tenant Fix ARM_CLIENT_ID; confirm the right ARM_TENANT_ID
AADSTS700024 Client assertion / token is expired Cert-based or OIDC token expired or clock skew Renew cert/federated credential; fix system clock
AADSTS90002 Tenant not found Wrong tenant ID (typo or wrong directory GUID) Set the correct ARM_TENANT_ID
AADSTS50034 User account does not exist in the tenant Logging in with a user not in that directory Use the right account, or invite/guest it
AADSTS50126 Invalid username or password Wrong user credentials (interactive login) Re-enter credentials; or switch to SPN auth
AADSTS650057 Invalid resource / permission not granted App lacks the required API permission Grant the API permission and admin-consent it
AADSTS500011 Resource principal not found in tenant The target resource/API isn’t in this tenant Confirm tenant; check the resource exists
AADSTS50020 User from a different tenant (guest) issue Cross-tenant login without proper guest setup Use a native account or fix B2B guest access

Two reading notes that save the most time.

Distinction The trap How to tell them apart
AADSTS (401) vs AuthorizationFailed (403) Both look like “access denied” An AADSTS code = authentication (fix credentials/tenant). AuthorizationFailed with no AADSTS = authorization (grant a role).
Secret value vs secret ID The portal shows both; people copy the ID The value is shown only once at creation and looks like a long random string; the ID is a GUID. Terraform needs the value.

Choosing how Terraform authenticates

The azurerm provider supports several authentication methods, and you must pick exactly one. Mixing them — for example having a service principal’s environment variables set and expecting CLI auth — is a top cause of “it authenticates as the wrong identity” confusion. Here is the menu, when each fits, and the trade-off.

Method Best for How you supply it Secret stored? Trade-off
Azure CLI Local development on your laptop az login; provider borrows the token No Tied to your user; not for automation
Service principal + client secret Simple CI/CD, getting started with automation ARM_CLIENT_ID/SECRET/TENANT_ID/SUBSCRIPTION_ID Yes (a password) Secret expires; must be stored safely
Service principal + certificate Automation needing stronger creds Provider client_certificate_path + env vars Yes (a cert file) Cert expires; key management overhead
OIDC / workload identity federation Modern CI/CD (GitHub Actions, Azure DevOps) ARM_USE_OIDC=true + federated credential No Setup is more involved; best long-term
Managed identity Terraform running on an Azure VM / runner ARM_USE_MSI=true No Only works on Azure-hosted compute

Local development — Azure CLI auth

The simplest path, for your own machine: log in once interactively, the provider uses your token, no secrets anywhere.

# Log in interactively (opens a browser); then confirm who you are and where you are
az login
az account show --query "{user:user.name, sub:name, subId:id, tenant:tenantId}" -o table
# The provider needs no credentials — it borrows the az CLI session.
# Still pin the subscription explicitly so you never act on the wrong one.
provider "azurerm" {
  features {}
  subscription_id = "00000000-0000-0000-0000-000000000000"
}

Note: recent azurerm provider versions (4.x) require subscription_id to be set explicitly (or via ARM_SUBSCRIPTION_ID) even with CLI auth — a change that surprises people upgrading from 3.x, where it was inferred. If you see subscription_id is a required provider property, that is this, not a login problem.

Automation — service principal with a client secret

For pipelines, create a service principal and pass its four coordinates as environment variables. Terraform reads ARM_* variables automatically — never put the secret in a .tf file.

# Create an SPN AND assign Contributor on a subscription in one step.
# This prints appId, password (the secret VALUE), and tenant — capture them ONCE.
az ad sp create-for-rbac \
  --name "sp-terraform-prod" \
  --role "Contributor" \
  --scopes "/subscriptions/00000000-0000-0000-0000-000000000000"
# Feed the four coordinates to Terraform via the ARM_* environment variables.
export ARM_CLIENT_ID="<appId from above>"
export ARM_CLIENT_SECRET="<password VALUE from above>"
export ARM_TENANT_ID="<tenant from above>"
export ARM_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000"

The four environment variables map one-to-one to the credentials, and getting any one wrong produces a specific failure. Keep this mapping handy.

Environment variable What it is Get it from Wrong value → error
ARM_CLIENT_ID The SPN’s app/client ID appId from create-for-rbac AADSTS700016 (app not found)
ARM_CLIENT_SECRET The SPN’s client secret value password from create-for-rbac AADSTS7000215 (invalid secret)
ARM_TENANT_ID The directory GUID tenant from create-for-rbac AADSTS90002 (tenant not found)
ARM_SUBSCRIPTION_ID The target subscription GUID az account show --query id 403 / not found at that scope

Modern CI/CD — OIDC, no stored secret

The best long-term pattern: the pipeline presents a short-lived token from its own identity provider, Entra trusts it via a federated credential, and no secret is ever stored. You enable it with one flag and configure the federated credential on the app registration — full setup in Entra App Registration: OIDC, Confidential Clients & Federated Credentials. The Terraform side is simply:

# Tell the provider to use OIDC instead of a client secret.
export ARM_USE_OIDC=true
export ARM_CLIENT_ID="<appId>"
export ARM_TENANT_ID="<tenant>"
export ARM_SUBSCRIPTION_ID="<sub id>"
# No ARM_CLIENT_SECRET — the pipeline supplies a federated token automatically.

Fixing the stale-token “it worked yesterday” failure

The most disorienting failure: nothing in your code changed, yet every command suddenly fails — or, subtler, commands succeed but act on the wrong subscription. The cause is almost always a cached token or a changed active context in your az session — you logged into a different subscription for another task, or a token expired.

The reliable reset, in order of escalation:

# 1. See the current context — is the active subscription/tenant the one you expect?
az account show --query "{sub:name, subId:id, tenant:tenantId, user:user.name}" -o table

# 2. If the active subscription is wrong, just switch it (no full re-login needed)
az account set --subscription "My Prod Subscription"

# 3. If tokens are stale/corrupt or you switched accounts, clear everything and re-login
az account clear
az login
az account set --subscription "00000000-0000-0000-0000-000000000000"

A subtle trap: environment variables silently win over your CLI login. If ARM_CLIENT_ID and friends are exported in your shell (from an earlier pipeline test, say), the provider uses those and ignores az login entirely — so you “log in” as yourself but Terraform acts as the old service principal. When a fix makes no sense, run env | grep '^ARM_' first and unset any leftovers to fall back to CLI auth.

Here is the precedence the provider follows, highest first — knowing it explains every “wrong identity” surprise.

Priority Credential source Wins when…
1 Explicit provider block arguments (client_id, etc.) You hard-code them in HCL (avoid for secrets)
2 ARM_* environment variables They are exported in the shell
3 OIDC / managed identity (ARM_USE_OIDC / ARM_USE_MSI) The corresponding flag is true
4 Azure CLI (az login) session Nothing above is set

Granting the service principal the right RBAC role

If authentication succeeds (you’re past the AADSTS codes) but operations fail with AuthorizationFailed, the SPN logs in fine — it just has no role, or a role at the wrong scope. The fix is a single role assignment; the trick is choosing the minimum role at the narrowest scope that still lets Terraform work.

The three roles you will actually use, and when each is right.

Role What it can do When Terraform needs it When it’s overkill
Reader View resources, read state — nothing else Read-only checks, terraform plan against existing infra Any apply that creates/changes resources (will 403)
Contributor Create/update/delete most resources The default for most Terraform automation If you don’t manage RBAC or assign roles in your config
Owner Contributor plus managing RBAC + assignments Only if your Terraform creates role assignments itself Almost always — it can grant itself anything
User Access Administrator Manage access (role assignments) only Pair with Contributor when you must assign roles If your config never touches RBAC

The default answer is Contributor, scoped as narrowly as the work allows. Grant it explicitly:

# Find the SPN's object/principal ID (NOT the appId) for the assignee
SP_ID=$(az ad sp show --id "<appId>" --query id -o tsv)

# Prefer the narrowest scope that works — a resource group over the whole subscription
az role assignment create \
  --assignee-object-id "$SP_ID" --assignee-principal-type ServicePrincipal \
  --role "Contributor" \
  --scope "/subscriptions/<sub>/resourceGroups/rg-terraform-prod"

# Source of truth for any 403 — every role this identity holds, and where
az role assignment list --assignee "<appId>" --all \
  --query "[].{role:roleDefinitionName, scope:scope}" -o table

A scope subtlety that causes “it can create the RG but not the VNet” confusion: a role on a resource group lets Terraform manage things inside it, but a resource declared at the subscription level (or in a different RG) is out of reach — you get a 403 on exactly that resource while everything else succeeds. Match the scope to the broadest thing your config touches.

Architecture at a glance

Hold this mental model and every error lands in the right box. A terraform plan or apply triggers the azurerm provider, which runs a two-step handshake before it touches a single resource.

Step one — authentication. The provider gathers credentials in the precedence order above (provider block, then ARM_* variables, then OIDC/MSI, then your az login session) and sends them to the Microsoft Entra ID token endpoint, asking for an access token for the Azure Resource Manager API. Entra checks the identity — is this client ID real in this tenant, is the secret correct and unexpired, is the tenant ID valid? If any check fails it returns a 401 with an AADSTS code, and the provider never reaches ARM. A failure here is always about identity, never permissions.

Step two — authorization. With a valid token, the provider calls Azure Resource Manager (management.azure.com). ARM doesn’t re-check who you are — the token proved that — it checks Azure RBAC: does this identity hold a role whose actions cover this operation at this scope? If not, ARM returns a 403 with AuthorizationFailed, naming the denied action and the scope. A failure here is always about a missing or wrong-scoped role, never credentials.

So the whole flow is: credentials → Entra ID (authenticate, 401 if wrong) → access token → Azure Resource Manager (authorize via RBAC, 403 if no role) → resource created. Tenant and subscription ride along throughout — the token comes from a specific tenant, ARM operations target a specific subscription — so a mismatch throws you out at the matching gate. When an error appears, ask one question first: did I get an AADSTS code (gate one) or AuthorizationFailed (gate two)? — and you’re already halfway to the fix.

Real-world scenario

Northwind Retail, a mid-size e-commerce company, is moving its Azure infrastructure from click-ops to Terraform. Priya, a platform engineer, has it working on her laptop with az loginplan and apply run clean. The goal: run the same Terraform from an Azure DevOps pipeline so changes go through pull requests instead of the portal.

The first pipeline run dies with Error: building AzureRM Client ... AADSTS7000215: Invalid client secret provided. Priya had created a service principal with az ad sp create-for-rbac and pasted the output into the pipeline’s secret variables. Reading the AADSTS code, she knows it’s an authentication failure — a bad secret, not a permission problem. The cause is immediate: she pasted the secret’s ID (a GUID, visible in the portal) instead of the secret value (the string shown only at creation, already lost). She resets it with az ad sp credential reset --id <appId>, captures the new value, and updates the variable.

The next run gets past authentication and fails with AuthorizationFailed: ... does not have authorization to perform action 'Microsoft.Network/virtualNetworks/write' over scope '/subscriptions/.../resourceGroups/rg-network'. No AADSTS code, so this is authorization: the SPN logs in fine but lacks a role — she had run create-for-rbac without --scopes, creating the identity but assigning nothing. She grants Contributor, scoped tightly to the two resource groups the pipeline manages, not Owner and not the whole subscription:

SP_ID=$(az ad sp show --id "$APP_ID" --query id -o tsv)
for rg in rg-network rg-app; do
  az role assignment create --assignee-object-id "$SP_ID" \
    --assignee-principal-type ServicePrincipal --role "Contributor" \
    --scope "/subscriptions/$SUB_ID/resourceGroups/$rg"
done

The pipeline runs green — until three months later it fails with AADSTS7000222: The provided client secret keys are expired. The secret had lapsed, and the lesson lands: secrets expire and become recurring outages. Priya migrates the pipeline to OIDC / workload identity federation (a federated credential, ARM_USE_OIDC=true, no stored secret), eliminating the expiry problem for good. Total time lost across the three incidents: about ninety minutes — every one a single command had she known the auth-vs-authz fork on day one.

Advantages and disadvantages

Choosing a service-principal-with-secret approach (the common starting point) versus the alternatives is a real trade-off. The explicit picture:

Advantages of SPN + client secret Disadvantages of SPN + client secret
Works anywhere — any CI system, any machine Secret expires (default 1–2 years) → recurring outages
Simple to set up; one create-for-rbac command Secret must be stored securely (a leak risk)
Well-documented; every example uses it Easy to paste the wrong value (ID vs value)
No dependency on Azure-hosted compute Manual rotation burden; often forgotten until it breaks
Easy to grant least-privilege RBAC per SPN A leaked secret = full SPN access until revoked

When each matters: a client secret is the right starting point — fastest to get a pipeline working and to learn the auth model. Once Terraform runs on a schedule or in a shared pipeline, the expiry and storage downsides start costing real incidents, and you move to OIDC / federated identity (no secret, no expiry, nothing to leak) or a managed identity on Azure compute. For purely local work, CLI auth beats all of them — you are the credential. The progression most teams follow: CLI auth locally → SPN-with-secret for the first pipeline → OIDC once it matters.

Hands-on lab

This walk-through creates a service principal, deliberately reproduces a 403, fixes it with a role assignment, and tears everything down. It uses only the CLI and a tiny Terraform config; everything is free (you create a resource group, which costs nothing).

1. Confirm your starting context. Make sure you are logged in and pointed at the right subscription.

az login
az account show --query "{sub:name, subId:id, tenant:tenantId}" -o table
SUB_ID=$(az account show --query id -o tsv)

2. Create a service principal WITHOUT a role (to reproduce the 403 on purpose).

az ad sp create-for-rbac --name "sp-tf-lab" --skip-assignment
# Capture appId, password (secret VALUE), tenant from the output.

3. Point Terraform at the SPN via environment variables.

export ARM_CLIENT_ID="<appId>"
export ARM_CLIENT_SECRET="<password value>"
export ARM_TENANT_ID="<tenant>"
export ARM_SUBSCRIPTION_ID="$SUB_ID"

4. Write a minimal config in main.tf:

terraform {
  required_providers {
    azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
  }
}

provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "lab" {
  name     = "rg-tf-auth-lab"
  location = "centralindia"
}

5. Run apply and watch it fail with a 403 — authentication works, authorization does not (you skipped the role).

terraform init
terraform apply -auto-approve
# Expected: AuthorizationFailed ... does not have authorization to perform action
#           'Microsoft.Resources/subscriptions/resourceGroups/write'

This is the exact error you came here to fix — and note: no AADSTS code, so it is authorization, not credentials.

6. Grant Contributor scoped to the subscription, then re-run.

SP_ID=$(az ad sp show --id "$ARM_CLIENT_ID" --query id -o tsv)
az role assignment create --assignee-object-id "$SP_ID" \
  --assignee-principal-type ServicePrincipal --role "Contributor" \
  --scope "/subscriptions/$SUB_ID"

# RBAC can take a minute to propagate; then:
terraform apply -auto-approve
# Expected: azurerm_resource_group.lab: Creation complete

7. Tear everything down — destroy the resource group, delete the SPN, unset variables.

terraform destroy -auto-approve
az ad sp delete --id "$ARM_CLIENT_ID"
unset ARM_CLIENT_ID ARM_CLIENT_SECRET ARM_TENANT_ID ARM_SUBSCRIPTION_ID

You have now walked the full arc — clean authentication, a deliberate authorization failure, the one-command fix, clean teardown — the muscle memory you want before it happens for real.

Common mistakes & troubleshooting

This is the heart of the article — the playbook to keep open mid-incident. Scan the Symptom column, find your row, run the Confirm command, apply the Fix. It spans the simple day-one mistakes and the subtler ones that bite later.

# Symptom Root cause Confirm (exact command / path) Fix
1 AADSTS7000215: Invalid client secret Wrong secret, or pasted the secret ID not the value Re-check the source of ARM_CLIENT_SECRET; the value is a long string, not a GUID Reset the secret; use the value shown once: az ad sp credential reset --id <appId>
2 AADSTS7000222: client secret keys are expired The secret passed its expiry date Portal → App registration → Certificates & secrets → check expiry Create a new secret; update ARM_CLIENT_SECRET; consider OIDC
3 AADSTS700016: Application not found in directory Wrong client ID, or SPN in a different tenant az ad sp show --id <appId> (errors if not in this tenant) Fix ARM_CLIENT_ID; confirm ARM_TENANT_ID matches the SPN’s tenant
4 AADSTS90002: Tenant not found Typo or wrong directory GUID in tenant ID az account show --query tenantId -o tsv and compare Set the correct ARM_TENANT_ID
5 AuthorizationFailed ... does not have authorization to perform action SPN authenticated but has no RBAC role (or wrong scope) az role assignment list --assignee <appId> --all -o table az role assignment create --role Contributor --scope <scope>
6 Worked yesterday, all commands now fail Stale cached token / expired session az account show (errors or shows stale context) az account clear && az login then az account set --subscription <id>
7 Acting on the wrong subscription silently Active subscription isn’t the intended one az account show --query name -o tsv az account set --subscription "<name or id>"; pin subscription_id in provider
8 Logged in as yourself but Terraform uses an old SPN Leftover ARM_* env vars override CLI auth env | grep '^ARM_' unset ARM_CLIENT_ID ARM_CLIENT_SECRET ARM_TENANT_ID ARM_USE_OIDC
9 building AzureRM Client: subscription_id is required azurerm 4.x needs explicit subscription Check provider version terraform version; no subscription_id set Set subscription_id in the provider block or ARM_SUBSCRIPTION_ID
10 No subscriptions found for the SPN SPN has no role anywhere, so it sees nothing az role assignment list --assignee <appId> --all (empty) Assign at least one role; or use --allow-no-subscriptions only for tenant-level work
11 Can create the RG but 403 on a resource inside it Role scoped too narrowly for what config touches Compare the denied action’s scope in the 403 to the role’s scope Widen the scope (sub-level) or add a role at the missing scope
12 AADSTS700024: client assertion is expired Cert/OIDC token expired or clock skew on the runner Check cert/federated-credential expiry; check date -u on the machine Renew the cert/federated credential; sync the system clock (NTP)
13 Default subscription not found after az login Logged into a tenant with no subscription selected az account list -o table (no IsDefault true) az account set --subscription <id>
14 Interactive az login fails in CI Pipelines can’t open a browser for interactive login Pipeline logs show a device-code/browser prompt hang Use SPN env vars or OIDC — never interactive login in automation
15 MFA required / conditional access blocks login A Conditional Access policy requires MFA for that user Login error mentions MFA / Conditional Access Use a service principal (exempt from user MFA) for automation
16 Backend init fails to authenticate (state in blob) Backend uses the same auth and it isn’t set yet terraform init errors before plan, on the backend Fix auth (this article) first; backend uses the same ARM_*/CLI creds

The three you will hit most, in detail

Secret ID vs secret value (row 1). When you create a client secret, the portal shows a Secret ID (a GUID, always visible) and a Value (a long opaque string, shown once). Terraform needs the value; people copy the visible ID, get AADSTS7000215, and assume the secret is broken — it isn’t, they grabbed the wrong field. The value can’t be retrieved later, so reset and capture the new one immediately (the password field below is the value):

az ad sp credential reset --id "<appId>" --query "{appId:appId, secret:password, tenant:tenant}" -o json

Auth succeeded but AuthorizationFailed (row 5). The classic “I configured everything right and it still fails.” Authentication did succeed — that’s why there’s no AADSTS code; the SPN just has no role. Read the 403: it names the exact action denied and the scope, which tells you precisely where to grant the role — at that scope, not broader.

The env-var override surprise (row 8). Wastes more time than any other, because the fix you apply (logging in) appears to work but changes nothing: the provider puts ARM_* variables above your CLI session, so if they’re exported, az login is irrelevant to Terraform. Whenever a credential fix has no effect, check env | grep '^ARM_' first — clearing stale variables, not re-logging-in, is the real fix.

Best practices

Security notes

Authentication is a security surface, so the way you fix it matters as much as that it works. The single biggest mistake — granting Owner or subscription-wide access “to make the error go away” — converts a permission glitch into a standing risk: a leaked credential then has the keys to everything. Treat least privilege as the default, not the cleanup.

Practice Why it matters How
Least-privilege RBAC Limits blast radius if a credential leaks Contributor (not Owner), scoped to an RG, one SPN per pipeline
Prefer keyless auth No secret to store, leak, or expire OIDC / workload identity federation; managed identity on Azure compute
Short-lived secrets Shrinks the exposure window If using secrets, set short expiry + rotation; never default to “never expires”
Secrets out of source control git history is forever; leaks get scraped ARM_* env vars or a vault; .gitignore *.tfvars; scan repos for secrets
Audit role assignments Detect over-privilege and stale SPNs Periodic az role assignment list; remove SPNs for retired pipelines
Separate prod and non-prod identities A dev leak can’t touch production Distinct SPNs/subscriptions per environment, distinct scopes

The highest-leverage improvement here is moving pipelines to OIDC / federated credentials: no secret in the pipeline, nothing to rotate or steal — trust comes from Entra trusting the CI provider’s signed, short-lived token. More setup than pasting a secret, but it permanently removes the most common credential-leak path in Terraform-on-Azure.

Cost & sizing

There is genuinely good news here: the authentication mechanisms themselves are free. Service principals, app registrations, role assignments, OIDC federated credentials, and the az CLI cost nothing — they are part of Microsoft Entra ID and Azure RBAC, included with any subscription. You are not sizing or paying for “auth”.

Item Cost Notes
Service principal / app registration Free Included with Entra ID
Client secrets / certificates Free The cert itself may have issuance cost if from a public CA
RBAC role assignments Free No charge per assignment
OIDC / federated credentials Free No charge; recommended
Managed identity Free The identity is free; the compute it runs on is not
Entra ID Premium features Paid (per user/month) Only if you use Conditional Access, PIM, etc. — not required for basic SPN auth

What can cost money is indirect. If a credential is over-privileged and leaked, the bill is whatever an attacker spins up (crypto-mining VMs are the classic) — so least privilege is a cost control, not just a security one. And the recurring time cost of an expired-secret outage — engineers dropping work to rotate, plus the deployment delay — is real; OIDC eliminates it for a one-time setup. The secure, keyless path is also the cheapest in total cost of ownership.

Interview & exam questions

These map to the HashiCorp Terraform Associate and Microsoft AZ-104 / AZ-500 style questions on Azure authentication and RBAC.

  1. What is the difference between a 401 and a 403 from the azurerm provider? A 401 is an authentication failure — Entra rejected the credentials (you get an AADSTS code) and the provider never reached ARM. A 403 is an authorization failure — the identity authenticated fine but lacks an RBAC role (AuthorizationFailed). Fix credentials/tenant for 401; grant a role for 403.

  2. You created a service principal and configured the provider, but apply returns AuthorizationFailed. Why? The SPN authenticates but has no RBAC role — creating the identity grants no permissions. Add a role assignment (e.g. Contributor) at an appropriate scope. Commonly happens when create-for-rbac ran without --scopes or with --skip-assignment.

  3. What does AADSTS7000215 mean and how do you fix it? “Invalid client secret provided” — the secret value is wrong, or you supplied the secret ID instead of its value. Reset with az ad sp credential reset, use the value shown once at creation, and update ARM_CLIENT_SECRET.

  4. How does the azurerm provider authenticate for local development versus CI/CD? Locally, Azure CLI authaz login and the provider borrows the token, no secrets. For CI/CD, a service principal with a secret or certificate, or better OIDC / workload identity federation (no stored secret), or a managed identity on Azure compute.

  5. Terraform worked yesterday and fails today with no code change. What are the top suspects? A stale/expired cached token, a changed active subscription, an expired client secret, or leftover ARM_* variables overriding your login. Confirm with az account show and env | grep '^ARM_'; fix with az account clear && az login and unsetting stray variables.

  6. Why is granting Owner to a Terraform service principal usually wrong? Owner can manage RBAC, so the SPN could grant itself any access — a leaked credential is catastrophic. Contributor covers creating/changing resources; only add role-management rights (or Owner) if your Terraform itself creates role assignments, and scope it tightly.

  7. What is the difference between an app registration and a service principal? The app registration is the application definition (client ID + secrets/certs). The service principal is the instance of that app in a tenant — the object RBAC roles attach to. You authenticate with the app’s credentials; you authorize the SPN via roles.

  8. How do you make Terraform target a specific tenant and subscription unambiguously? Set ARM_TENANT_ID and ARM_SUBSCRIPTION_ID (or tenant_id/subscription_id in the provider block) rather than relying on whatever the az CLI’s active context happens to be — critical when you have access to multiple tenants or subscriptions.

  9. Why prefer OIDC / federated identity over a client secret for pipelines? OIDC stores no secret — the CI provider presents a short-lived token Entra trusts via a federated credential — so there’s nothing to expire, rotate, store, or leak. Client secrets expire (recurring outages) and are a standing leak risk; OIDC removes both.

  10. An SPN authenticates but terraform plan reports No subscriptions found. What’s wrong? The SPN has no role assignment anywhere, so it sees no subscription. Assign at least one role at a subscription or resource-group scope. (For genuinely tenant-level work, --allow-no-subscriptions exists, but that’s the exception.)

Quick check

  1. You get an error containing AADSTS7000222. Is this an authentication or an authorization problem, and what’s the fix?
  2. The provider returns AuthorizationFailed with no AADSTS code. What did you forget to do?
  3. Terraform is acting as an old service principal even though you just ran az login as yourself. What’s the most likely cause?
  4. Which RBAC role is the right default for a Terraform automation service principal, and which should you avoid?
  5. Name the keyless authentication method that removes stored secrets from a CI/CD pipeline.

Answers

  1. AuthenticationAADSTS7000222 means the client secret expired. The fix is to create a new secret and update ARM_CLIENT_SECRET (and ideally migrate to OIDC so it can’t recur).
  2. You forgot to assign an RBAC role to the service principal. It authenticated fine but has no permission; run az role assignment create --role Contributor --scope <scope>.
  3. Leftover ARM_* environment variables in your shell — they take precedence over the CLI session, so az login is ignored. Run env | grep '^ARM_' and unset them.
  4. Contributor is the right default; Owner should be avoided unless your Terraform itself manages RBAC assignments, because Owner can grant any access.
  5. OIDC / workload identity federation (or a managed identity when Terraform runs on Azure compute) — both authenticate without a stored secret.

Glossary

Next steps

TerraformAzureazurermService PrincipalAuthenticationRBACTroubleshootingEntra ID
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading