You will install the Azure CLI, authenticate, select the right subscription, and set defaults so later commands are short and safe. By the end you will have a reproducible az workflow ready for every other Azure lab.

This is deliberately the first lab in the Cloud & Azure series, because every other lab assumes you can already run az commands against the correct subscription. Getting this foundation right prevents the two most common beginner disasters: deploying resources into the wrong subscription (surprise billing) and typing the same long flags on every command (typos and drift). We will also create a service principal now so that when you reach the DevOps labs you already have a non-human identity ready for CI/CD.

Learning Objectives

  • Authenticate interactively and view your subscriptions.
  • Set the active subscription and default resource group/location.
  • Create a service principal for automation.
  • Time: ~1 hour · Difficulty: Beginner · Prereqs: an Azure account.

Architecture Overview

The flow is simple but worth understanding. When you run az login, the CLI never sees your password, it opens a browser window where you authenticate directly against Microsoft Entra ID (formerly Azure Active Directory). Entra ID returns an access token and a refresh token, which the CLI caches locally under ~/.azure. Every subsequent command attaches that token to calls against the Azure Resource Manager (ARM) API.

A single Entra ID account can have access to multiple subscriptions (billing and quota boundaries). The CLI always executes against exactly one active subscription at a time, which is why selecting it explicitly, instead of trusting whatever happens to be the default, is a core habit in this lab. Finally, the service principal you create is a second, non-human identity in the same tenant, scoped to only the permissions your automation needs.

Environment Setup

Prerequisites

  • Windows 10/11, macOS, or a mainstream Linux distribution with a terminal you can open.
  • Azure CLI 2.5x (install per platform), an Azure account (the free tier works), and a browser for the interactive login.
  • Assumed skills: opening a terminal, running commands, and copying values between windows.

You will need: the Azure CLI (install docs) and an Azure account.

Before you begin: know which subscription you should deploy into to avoid surprise billing. If you only have one, note its name anyway, you will type it in Step 02.

Install the CLI using your platform's package manager rather than a manual download, so updates arrive automatically:

# Windows (elevated PowerShell)
winget install -e --id Microsoft.AzureCLI

# macOS
brew update && brew install azure-cli

# Debian/Ubuntu
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash

After installing, open a new terminal session (so the updated PATH is picked up) and verify the installation. The version check confirms the binary runs and shows which components are installed:

$ az version
{ "azure-cli": "2.67.0", "azure-cli-core": "2.67.0", "azure-cli-telemetry": "1.1.0", "extensions": {} }

Anything in the 2.5x-2.6x range is fine for this lab. If az version works, your PATH is correct and you can move on. Common mistake: running the verify command in the same terminal window that was open during the install, the shell caches the old PATH, so it reports az: command not found even though the install succeeded.

Three optional quality-of-life settings worth deciding up front. First, the CLI sends anonymous usage telemetry by default; on a lab machine this is harmless, but know how to turn it off. Second, the CLI can prompt to upgrade itself mid-command, which is distracting in scripts, control it explicitly. Third, tab completion saves real time once commands get long:

# Disable telemetry (optional)
az config set core.collect_telemetry=false

# Never auto-upgrade mid-command; upgrade deliberately instead
az config set auto-upgrade.enable=no
az upgrade --yes   # run this yourself when you choose to; without --yes it prompts for confirmation

# Enable tab completion for bash/zsh (adds a line to your shell profile)
az completion

All of these write to the same ~/.azure/config file you will see again in Step 03, so there is one place to audit your CLI's behavior.

Step-by-Step Execution

01
Authenticate

az login starts an interactive OAuth flow. On a machine with a browser it opens your default browser; on a headless server it prints a device-code URL instead. Sign in with the account that owns your Azure subscription. After a successful login the CLI prints a JSON array of every subscription your account can see, read it, because it tells you exactly which subscription landed as the default.

az login
Opens a browser to sign in to Microsoft Entra ID.

Expected output (abbreviated, one object per subscription):

$ az login
A web browser has been opened at https://login.microsoftonline.com/organizations/oauth2/v2.0/authorize [ { "cloudName": "AzureCloud", "homeTenantId": "9b1e5f2a-....", "id": "a1b2c3d4-0000-1111-2222-333344445555", "isDefault": true, "name": "Pay-As-You-Go", "state": "Enabled", "tenantId": "9b1e5f2a-...." } ]

How to verify: run az account show immediately. If it returns subscription JSON (instead of an error like Please run 'az login'), your token cache is working.

az account show --query "{Name:name, Id:id, Tenant:tenantId}" -o table

Common mistake: if your account belongs to multiple Entra ID tenants (for example a work tenant and a personal tenant), az login may sign you into the wrong one. Pin the tenant explicitly with az login --tenant <tenant-id-or-domain>. On a headless Linux box with no browser, use az login --use-device-code and complete the flow from any device.

Security note worth remembering: the tokens from this login are cached on disk under ~/.azure (on Linux/macOS) or %USERPROFILE%\.azure (on Windows), protected only by your user account's file permissions. Anyone who can read your home directory can act as you in Azure until the tokens expire. On a shared or lab machine, run az logout when you are done, it clears the cache, and Step 01 is cheap to repeat.

What this teaches: Interactive login never exposes your password to the CLI; the browser talks to Entra ID and the CLI only caches the resulting tokens.

02
Select the subscription

Even if you only have one subscription, make the selection explicit. Commands like az group create never ask which subscription to use, they silently target whatever is active, and the active subscription persists across terminal sessions. Setting it by name is safer than by ID because a typo in a name fails loudly, while a pasted GUID from the wrong subscription can look plausible.

$ az account list -o table && az account set --subscription "Pay-As-You-Go"
Name IsDefault Pay-As-You-Go True

az account set produces no output on success, silence is normal. Confirm the switch took effect by checking the isDefault flag:

$ az account list -o table --query "[].{Name:name, IsDefault:isDefault, State:state}"
Name IsDefault State --------------- ----------- ------- Pay-As-You-Go True Enabled Visual Studio False Enabled

Common mistake: confusing az account set (chooses which subscription commands target) with subscription permissions. If a subscription shows State: Disabled or is missing from the list entirely, that is a billing or RBAC issue on the account, not a CLI problem, no amount of re-login will fix it.

To see exactly what you are allowed to do in the active subscription, list your own role assignments. On a personal account you will see Owner; on a work or school account you may only have Reader, which is enough to follow along with every show/list command here but not the creates:

$ az role assignment list --assignee $(az ad signed-in-user show --query id -o tsv) --query "[].{Role:roleDefinitionName, Scope:scope}" -o table
Role Scope ------ ----------------------------------------------- Owner /subscriptions/a1b2c3d4-0000-1111-2222-333344445555

What this teaches: Selecting the subscription explicitly is the habit that prevents surprise bills; the active subscription persists across sessions.

03
Set defaults to shorten commands

Almost every az command needs a resource group (-g) and often a location (-l). az configure --defaults stores these in ~/.azure/config so you can omit them. This is not just convenience: it makes scripts reproducible and removes a whole class of "forgot the flag, deployed to the wrong region" errors. Note that defaults live on this machine only, a teammate running the same script elsewhere needs the same config or the explicit flags.

az configure --defaults group=tyf-rg location=eastus

The command is silent on success. Read the defaults back to verify they were written:

$ az config get defaults -o table
Name Value Source ---------------- ------- ------------- defaults.group tyf-rg /home/taki/.azure/config defaults.location eastus /home/taki/.azure/config

How to verify: the real test is behavioral, after Step 04 validation, az group show -n tyf-rg should work without any location or subscription flags. Common mistake: setting a default resource group that does not exist yet. Defaults are just strings; the CLI will not create the group for you, and the first command that relies on the default will fail with ResourceGroupNotFound until you create it (we do that in the validation step).

Two more things worth knowing about defaults. First, an explicit flag always wins over a default, az group create -n other-rg ignores defaults.group entirely, so defaults never trap you. Second, choosing a location is a real decision: it determines data residency, latency, and which VM sizes are available. List what your subscription offers before committing to one:

$ az account list-locations --query "[?metadata.regionType=='Physical'].{Name:name, DisplayName:displayName}" -o table | head -8
Name DisplayName ------------- ------------------- eastus East US eastus2 East US 2 canadacentral Canada Central canadaeast Canada East westus2 West US 2 westeurope West Europe

For a Canadian lab, canadacentral (Toronto) keeps data in-country; this lab uses eastus purely because it has the broadest service availability. Pick one, set it as your default, and stay consistent across labs so resource groups and networks line up.

What this teaches: Defaults live in ~/.azure/config on this machine only; they shorten commands but never substitute for knowing where a command lands.

04
Create a service principal for automation

A service principal lets pipelines authenticate without your personal account. Your interactive login from Step 01 is tied to you as a person, it expires, requires MFA, and should never be pasted into a CI system. The service principal we create here gets the Contributor role scoped to one subscription only, following least privilege: it can manage resources in that subscription but cannot touch billing, other subscriptions, or Entra ID itself.

az ad sp create-for-rbac --name tyf-ci --role Contributor --scopes /subscriptions/$(az account show --query id -o tsv)

The output is shown exactly once. The password field is the client secret, it is not stored anywhere retrievable, so capture it now into a password manager or secret store:

$ az ad sp create-for-rbac --name tyf-ci --role Contributor --scopes /subscriptions/$(az account show --query id -o tsv)
{ "appId": "b2c3d4e5-1111-2222-3333-444455556666", "displayName": "tyf-ci", "password": "EXAMPLE~8xQ~secret~value~shown~once", "tenant": "9b1e5f2a-...." }

The three fields map directly onto a login: appId is the username, password is the secret, and tenant identifies the directory. Verify the principal can actually authenticate before you trust it in a pipeline:

$ az login --service-principal -u b2c3d4e5-1111-2222-3333-444455556666 -p "EXAMPLE~8xQ~secret~value~shown~once" --tenant 9b1e5f2a-.... --query "[].name" -o tsv
Pay-As-You-Go

Log back in as yourself afterwards (az login) so your interactive identity is active for the rest of the lab. Common mistake: leaving the secret in your shell history or a script in a git repo. Clear it from history (history -d <line> or start the command with a leading space) and treat any committed secret as compromised, rotate it with az ad sp credential reset.

Lifecycle note: secrets created by create-for-rbac expire (default one year). A pipeline that works today and dies next March with AADSTS7000222 (expired client secret) is a classic. Put the expiry date in your calendar, and rotate on a schedule rather than after an outage:

# See when the current credential expires
az ad app credential list --id b2c3d4e5-1111-2222-3333-444455556666 --query "[].endDateTime" -o tsv

# Rotate: create a new secret, update the pipeline, THEN delete the old one
az ad sp credential reset --id b2c3d4e5-1111-2222-3333-444455556666

Rotation order matters: deploy the new secret to your pipeline first, verify a run succeeds, and only then remove the old credential, doing it in the other order guarantees downtime.

Scoping note: we scoped the role to the whole subscription because this lab's later exercises touch multiple resource groups. In production you would narrow it further, --scopes /subscriptions/<sub>/resourceGroups/tyf-rg limits the principal to a single resource group, and a role like Contributor on that scope means even a leaked secret cannot reach anything outside it.

What this teaches: A service principal gives automation its own identity with its own least-privilege scope, separate from your personal account.

Step Flow Map

Each box is one step in the lab; work them in order, since later steps build on earlier state.

Progress So Far

All four stages are complete: you hold a cached token for your account, the CLI is pinned to the intended subscription, defaults shorten every future command, and a least-privilege service principal exists for automation. The only thing left is to prove the whole chain works end to end.

Reading CLI Output: Formats and JMESPath

Everything in this lab (and every later one) gets easier once you control what the CLI prints. Every az command accepts an -o / --output flag with four formats you will actually use: json (the default, complete, best for piping to files), table (human-readable summary), tsv (tab-separated values with no headers, ideal for shell variables), and jsonc (colorized JSON for reading). The same command in three formats:

az account show                        # full JSON document
az account show -o table               # one readable row
az account show --query id -o tsv      # just the subscription GUID, nothing else

The --query flag uses JMESPath, a query language for JSON, and it is the single most useful skill for scripting with az. Two patterns cover most daily work: renaming and reshaping fields into a clean table, and filtering a list down to matching items:

# Reshape: pick and rename fields for a table
az account list --query "[].{Subscription:name, ID:id, Default:isDefault}" -o table

# Filter: only enabled subscriptions, and only their names
az account list --query "[?state=='Enabled'].name" -o tsv

The tsv pattern is how you capture values into shell variables without parsing JSON yourself, you already used it in Step 04, where $(az account show --query id -o tsv) fed the subscription ID into the --scopes argument:

$ SUB=$(az account show --query id -o tsv) && echo "/subscriptions/$SUB"
/subscriptions/a1b2c3d4-0000-1111-2222-333344445555

Common mistake: forgetting -o tsv inside $(). Without it the variable contains a JSON string with quotes ("a1b2c3d4-..."), and the quotes silently corrupt whatever you build from it, resource IDs that almost-but-not-quite match are a miserable thing to debug.

Where the CLI Stores Its State

Everything you configured in this lab lives in one directory, and knowing its layout turns "the CLI is acting weird" from superstition into debugging. On Linux/macOS it is ~/.azure; on Windows, %USERPROFILE%\.azure. The contents that matter:

  • config, your defaults from Step 03 plus telemetry/upgrade settings, in plain INI format. Safe to read and hand-edit.
  • azureProfile.json, which account and subscription are active. Deleting it is a crude but effective "log out".
  • msal_token_cache.json (or the msal_cache/service-principal entries), the actual tokens. Treat this like a password file: never commit it, never sync it to cloud storage, never copy it to another machine.
  • logs/, command history when logging is enabled. Note that failed commands can land here with their arguments, another reason secrets belong in variables, not on command lines.

A fast way to confirm what is actually on disk:

$ ls -la ~/.azure | head -8
drwx------ 12 taki staff 384 Mar 3 10:14 . -rw------- 1 taki staff 214 Mar 3 10:14 azureProfile.json -rw------- 1 taki staff 389 Mar 3 09:58 config -rw------- 1 taki staff 8122 Mar 3 10:14 msal_token_cache.json

Notice the permissions: 0600 on the token cache and profile, 0700 on the directory. If you ever see these files world-readable, something (often an over-eager chmod -R) has weakened them, fix with chmod 700 ~/.azure && chmod 600 ~/.azure/*.json.

Using the Service Principal in Scripts and CI

The point of Step 04 was to stop pasting credentials into command lines. In real automation, the three values (appId, password, tenant) travel as environment variables or CI secrets, never as literals in a script. The standard variable names the Azure tooling ecosystem expects:

# Store in a CI secret store or a local .env that is git-ignored - never in the repo
export AZURE_CLIENT_ID="b2c3d4e5-1111-2222-3333-444455556666"
export AZURE_CLIENT_SECRET="EXAMPLE~8xQ~secret~value~shown~once"
export AZURE_TENANT_ID="9b1e5f2a-...."
export AZURE_SUBSCRIPTION_ID="a1b2c3d4-0000-1111-2222-333344445555"

A script then logs in non-interactively without exposing the secret in its process arguments (which any user on the box could read via ps):

#!/usr/bin/env bash
set -euo pipefail

: "${AZURE_CLIENT_ID:?set AZURE_CLIENT_ID}"      # fail fast if a secret is missing
: "${AZURE_CLIENT_SECRET:?set AZURE_CLIENT_SECRET}"
: "${AZURE_TENANT_ID:?set AZURE_TENANT_ID}"

az login --service-principal \
  -u "$AZURE_CLIENT_ID" \
  -p "$AZURE_CLIENT_SECRET" \
  --tenant "$AZURE_TENANT_ID" \
  -o none        # don't echo subscription JSON (and token context) into CI logs

az account set --subscription "$AZURE_SUBSCRIPTION_ID"
echo "Authenticated as: $(az ad signed-in-user show --query userPrincipalName -o tsv 2>/dev/null || echo $AZURE_CLIENT_ID)"

Two details here are deliberate. -o none suppresses output because CI logs are often retained and broadly visible; and the : "${VAR:?}" guards turn a confusing downstream auth failure into an immediate, readable error. In GitHub Actions you will skip this entirely and use the azure/login action with OIDC federation, no stored secret at all, which the DevOps lab covers.

How It Works

The Azure CLI is a Python program that translates each az command into one or more HTTPS calls against the Azure Resource Manager REST API. It never talks to your resources directly; it authenticates, attaches a bearer token to an API request, and prints the JSON response. Understanding this explains almost every behavior in the lab: why login is separate from subscription selection, why output formats are just views over JSON, and why a failing command usually means the API rejected the request.

Authentication uses OAuth 2.0 against Microsoft Entra ID. In the interactive flow, your browser handles the sign-in and Entra ID hands tokens back to the CLI, which caches them under ~/.azure using MSAL. The CLI never sees your password, but the cached tokens are as powerful as your account until they expire, which is why file permissions on that directory matter and why az logout exists.

Everything after login is scoping. The subscription is the billing and quota boundary the API bills against; the resource group is the lifecycle boundary; the location is the region. Defaults stored in ~/.azure/config are pure client-side convenience: they fill in flags you omit, they never change what the API allows, and an explicit flag always wins over a default.

Testing & Validation

Validation has two parts: confirm who the CLI thinks you are and where commands will land, then exercise the defaults by creating a real resource. The resource group created here is the same tyf-rg referenced by later labs, so this step doubles as scaffolding.

az account show -o table && az group create -n tyf-rg -l eastus
$ az account show -o table
EnvironmentName IsDefault Name State TenantId ----------------- ----------- ------------- ------- ---------------- AzureCloud True Pay-As-You-Go Enabled 9b1e5f2a-....
$ az group create -n tyf-rg -l eastus
{ "id": "/subscriptions/a1b2c3d4-.../resourceGroups/tyf-rg", "location": "eastus", "name": "tyf-rg", "properties": { "provisioningState": "Succeeded" } }

You should see your active subscription and a created resource group using your defaults. If so, your CLI is ready. Three final checks worth running once, so you know what "healthy" looks like before something breaks in a later lab:

# 1. The resource group exists in the expected region
az group show -n tyf-rg --query "{Name:name, Location:location, State:properties.provisioningState}" -o table

# 2. The service principal's role assignment is active
az role assignment list --assignee b2c3d4e5-1111-2222-3333-444455556666 -o table

# 3. No stale defaults pointing somewhere unexpected
az config get defaults -o table

Check 2 should show exactly one Contributor assignment scoped to your subscription, nothing broader. If any check surprises you, stop and fix it now; every later lab builds on this state.

Cost note: an empty resource group is free, it is a container, not a billable resource. Only the resources you later put inside tyf-rg (VMs, networks, storage) generate charges. The service principal is also free; Entra ID app registrations cost nothing at this scale. So nothing in this lab needs cleanup, and leaving tyf-rg in place is intentional: later labs deploy into it. If you ever do want a clean slate, deleting the group deletes everything inside it in one shot:

az group delete -n tyf-rg --yes --no-wait
Destructive: removes the group and every resource inside it. Not needed for this lab.

Troubleshooting

Quick-reference for the failures you are most likely to hit in this lab.

SymptomLikely causeFix
az: command not foundThe installer updated PATH but the current shell still has the old oneOpen a new terminal session, then verify with az version.
Login lands in the wrong tenantThe account exists in multiple Entra ID tenantsPin it with az login --tenant <tenant-id-or-domain>.
Subscription missing from the listDisabled subscription or insufficient RBAC, not a CLI bugCheck the portal; no re-login will conjure access you do not have.
Defaults seem to be ignoredAn explicit flag always overrides a defaultRun az config get defaults to see what is actually stored.
Service principal login fails months laterThe client secret expired (default lifetime is one year)Rotate with az ad sp credential reset and update the stored secret.
Browser login fails on Windows with a broker errorThe Web Account Manager broker intercepting the flowSet core.enable_broker_on_windows=false or fall back to az login --use-device-code.
Troubleshooting
  • az: command not found: the CLI is not on PATH; reinstall or restart your shell.
  • Wrong subscription used: always confirm with az account show before deploying.
  • SP secret printed once: store it immediately in a secret manager; it is not retrievable later.
  • Browser login fails behind a proxy or WAM errors on Windows: bypass the Windows broker with az config set core.enable_broker_on_windows=false, or fall back to az login --use-device-code, which works through any browser on any device.
  • az account set fails with "The subscription ... could not be found": the name is case-sensitive and must match exactly, copy it from az account list -o table, and quote names that contain spaces.
  • Insufficient privileges to create a service principal: your Entra ID role (not your subscription role) controls this. If the tenant restricts app registrations, ask an admin to enable "Users can register applications" or have them create the SP and hand you the credentials.

Extension Ideas

  • Deploy your first network in Virtual Network Deployment.
  • Use the service principal in GitHub Actions.
  • Codify everything with Infrastructure as Code.
  • Script the whole setup: put Steps 01-03 into a setup-az.sh that checks az account show first and exits early if already configured, idempotent setup scripts pay off every time you rebuild a machine.

Key Results

  • Authenticated and confirmed the correct subscription.
  • Set defaults that shorten and de-risk every later command.
  • Created a least-privilege service principal for automation.
  • Validated by creating a resource group from defaults.

Key Takeaways

  • az login authenticates you to Entra ID, not to a subscription, always follow it with an explicit az account set and verify with az account show.
  • az configure --defaults stores group/location per machine in ~/.azure/config; it shortens commands but never substitutes for knowing where a command will land.
  • A service principal's secret is displayed exactly once. Capture it immediately, scope it to the smallest possible target, and rotate it if it ever touches a repo or log.
  • Every az mutation silently targets the active subscription, make az account show a reflex before any create or delete.
  • Validate your toolchain once, early: a healthy az version + az account show + az group show baseline makes later failures much easier to isolate.

Practice Challenges

Challenge 1: Script the whole setup

Goal: Write setup-az.sh that checks az account show first and exits early if already configured, then sets the subscription and defaults.

Hint: Use set -euo pipefail and quote every variable; idempotent scripts pay off each time you rebuild a machine.

Challenge 2: Scope a principal to one resource group

Goal: Create a second service principal whose Contributor role is scoped to tyf-rg only, then prove it cannot list another group.

Hint: Pass the full resource group ID to --scopes and test the boundary deliberately.

Challenge 3: Build a subscription report with JMESPath

Goal: Output name, id, and state for every subscription using a single az account list pipeline.

Hint: Combine --query reshaping with -o tsv; tsv with no headers is made for this.