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:
- Tell a Terraform authentication failure (401) from an authorization failure (403) within the first thirty seconds, from the error string alone.
- Read the common AADSTS error codes —
AADSTS7000215,AADSTS700016,AADSTS90002,AADSTS50034,AADSTS700024— and name the exact cause of each. - Choose the right authentication method for the situation: Azure CLI auth for local development, a service principal with a client secret or certificate, or OIDC / managed identity for CI/CD, and configure each in the provider.
- Clear a stale cached token and reset your
azsession so a “worked yesterday” failure resolves in one command. - Point Terraform at the correct tenant and subscription explicitly, and stop the “wrong directory” 403 for good.
- Grant a service principal the minimum RBAC role it needs (usually Contributor on a subscription or resource group) with a single
az role assignment create, and understand why Owner is the wrong default. - Rotate an expired client secret and feed the new value to Terraform without committing it to git.
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 login — plan 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
- Read the error class first, always.
AADSTS= authentication (fix credentials/tenant).AuthorizationFailed= authorization (grant a role). This single fork resolves 90% of cases before you touch anything. - Use CLI auth locally, service principals only for automation. Don’t create an SPN to run Terraform on your own laptop;
az loginis simpler and leaks nothing. - Default to Contributor, never Owner. Owner lets the identity grant itself any access — only use it if your Terraform actually creates role assignments, and then prefer adding User Access Administrator alongside Contributor.
- Scope roles as narrowly as the work allows. Prefer a resource group over a subscription; create one SPN per pipeline/environment rather than one god-SPN for everything.
- Pin
subscription_id(andtenant_id) explicitly in the provider or via env vars, so Terraform can never act on whatever subscription happens to be active. - Never commit secrets to git. Pass credentials via
ARM_*environment variables or a secrets store; keep them out of.tfand.tfvarsfiles (and.gitignorethe latter). - Move to OIDC / federated identity when you can. It removes the stored secret and its expiry entirely — the biggest recurring source of pipeline outages.
- Track secret expiry like a deadline. If you must use secrets, set a calendar reminder before they lapse; an expired secret is a guaranteed future incident.
- Clear stale state deliberately. When something “worked yesterday”, run
az account clear && az loginand check for leftoverARM_*variables before assuming a deeper bug. - Confirm RBAC with
az role assignment list --assignee <id> --allas your source of truth for any 403 — don’t guess what an SPN can do, look it up.
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.
-
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
AADSTScode) 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. -
You created a service principal and configured the provider, but
applyreturnsAuthorizationFailed. 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 whencreate-for-rbacran without--scopesor with--skip-assignment. -
What does
AADSTS7000215mean 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 withaz ad sp credential reset, use the value shown once at creation, and updateARM_CLIENT_SECRET. -
How does the azurerm provider authenticate for local development versus CI/CD? Locally, Azure CLI auth —
az loginand 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. -
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 withaz account showandenv | grep '^ARM_'; fix withaz account clear && az loginand unsetting stray variables. -
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.
-
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.
-
How do you make Terraform target a specific tenant and subscription unambiguously? Set
ARM_TENANT_IDandARM_SUBSCRIPTION_ID(ortenant_id/subscription_idin the provider block) rather than relying on whatever theazCLI’s active context happens to be — critical when you have access to multiple tenants or subscriptions. -
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.
-
An SPN authenticates but
terraform planreportsNo 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-subscriptionsexists, but that’s the exception.)
Quick check
- You get an error containing
AADSTS7000222. Is this an authentication or an authorization problem, and what’s the fix? - The provider returns
AuthorizationFailedwith no AADSTS code. What did you forget to do? - Terraform is acting as an old service principal even though you just ran
az loginas yourself. What’s the most likely cause? - Which RBAC role is the right default for a Terraform automation service principal, and which should you avoid?
- Name the keyless authentication method that removes stored secrets from a CI/CD pipeline.
Answers
- Authentication —
AADSTS7000222means the client secret expired. The fix is to create a new secret and updateARM_CLIENT_SECRET(and ideally migrate to OIDC so it can’t recur). - 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>. - Leftover
ARM_*environment variables in your shell — they take precedence over the CLI session, soaz loginis ignored. Runenv | grep '^ARM_'andunsetthem. - Contributor is the right default; Owner should be avoided unless your Terraform itself manages RBAC assignments, because Owner can grant any access.
- OIDC / workload identity federation (or a managed identity when Terraform runs on Azure compute) — both authenticate without a stored secret.
Glossary
- azurerm provider — The Terraform plugin that manages Azure resources; it authenticates to Entra ID then calls Azure Resource Manager.
- Authentication — Proving who you are to get an access token. Failure → 401 with an
AADSTScode. - Authorization — Proving you’re allowed to do an operation, via RBAC. Failure → 403
AuthorizationFailed. - AADSTS code — A specific error number from the Entra ID (Azure AD) token service identifying an authentication failure.
- Microsoft Entra ID — Azure’s identity service (formerly Azure Active Directory); issues the tokens Terraform uses.
- Tenant (directory) — An Entra ID instance; the identity boundary. Different organisations (and your personal account) are different tenants.
- Subscription — A billing and resource container within a tenant; Terraform targets one subscription.
- App registration — The application definition in Entra ID; holds the client ID and any secrets/certificates.
- Service principal (SPN) — The tenant-local instance of an app registration; the object RBAC roles attach to.
- Client ID (
appId) — The public identifier of the app registration / service principal. - Client secret — A password credential for a service principal; expires and must be stored securely.
- RBAC role — A named set of allowed actions: Reader (view), Contributor (manage resources), Owner (manage resources + access).
- Scope — Where a role applies: a subscription, a resource group, or a single resource.
- Role assignment — The binding of a role to an identity at a scope; without one, an authenticated SPN can do nothing.
- OIDC / workload identity federation — A keyless method where Entra trusts a short-lived token from your CI provider; no stored secret.
- Managed identity — An Azure-managed credential for compute (VMs, runners); no secret to handle.
Next steps
- Configure remote state next — the backend uses the same authentication you just fixed: Terraform on Azure: Remote State in Blob Storage with State Locking & Workspaces.
- Go deep on the identity you created and the keyless future: Entra App Registration: OIDC, Confidential Clients & Federated Credentials.
- Understand managed identities end to end for Azure-hosted Terraform runners: Entra Managed Identities Deep Dive: User-Assigned, FIC & RBAC.
- Store the secrets your infrastructure consumes properly: Azure Key Vault: Secrets, Keys & Certificates.
- Compare service principals and managed identities in a cluster context: AKS: Managed Identity vs Service Principal for Cluster Auth.