Python DefaultAzureCredential and Azure Key Vault — the credential that is not a key
The chain walked in. No key sat in the process.
<!-- hal:authoritative:yaml -->
The chain walked in. No key sat in the process.
§I — Frame
Thursday's lesson named a group. list_managed_instances spoke the MIG. Last night's VM name was a leftover. Coin of that day: the instance that is not a name. The cloud seat was GCP because the Cert seat was PCA.
Today the Cert seat is AZ-900, the second Microsoft visit. 08-11 opened the hierarchy, the governance spine, and the composite SLA. Identity sat in that lesson as a referenced chapter. The published skill area that lesson left on the table is Describe Azure identity, access, and security. The concrete service that skill area opens for an ops tool is Key Vault.
The hop is the other way around from 08-11. That day the tool fanned out across Azure Resource Manager and counted subscriptions. ARM is the door every control-plane call walks through. Key Vault is a data-plane door. The token that opens it is an Entra token. The Python client that mints the token without a password in the repo is DefaultAzureCredential.
Name the duty. Coin it: the credential that is not a key.
The Python ops tool talks to azure.keyvault.secrets.SecretClient. It constructs the client with a vault URL and a DefaultAzureCredential. It lists secret properties. It does not print a secret value. It does not read AZURE_STORAGE_KEY. It does not open a connection string. If AZURE_CLIENT_SECRET is already in the environment, the chain found a key you brought, and the tool says so.
08-11 already taught a semaphore around ARM reads. This lesson does not reopen that governor. It assumes the call can be made. The question is which identity made it.
§II — Foundations: four facts about the chain
**Fact one. DefaultAzureCredential is a walk, not a secret.**
azure.identity.DefaultAzureCredential tries sources in a fixed order until one returns a token. The production-relevant rungs, in the order the library walks them, are:
- Environment (
AZURE_TENANT_ID,AZURE_CLIENT_ID,AZURE_CLIENT_SECRETor a certificate). - Workload identity (the projected federated token an AKS or GitHub OIDC job already holds).
- Managed identity (the IMDS endpoint on an Azure VM, App Service, Function, Container App, or Arc machine).
- Azure CLI / Azure PowerShell / Azure Developer CLI (the laptop login you already completed).
The constructor takes no password. The object is a walker. Each rung is a credential that already exists outside the process: a managed identity the platform attached, a CLI session the operator signed, a federated token the job minted. The process did not invent a key. It asked who was already standing there.
If rung 1 succeeds, you brought a client secret. The chain still works. The coin fails. A tool that claims the credential that is not a key must treat a populated AZURE_CLIENT_SECRET as a finding, not as a convenience.
**Fact two. SecretClient takes a vault URL and a credential. It does not take a key.**
The Bootcamp az-900 note on Key Vault is three nouns: secrets, keys, certificates (az-900 README, Azure Identity and Security / Azure Key Vault). The cheatsheet row is one line: "Secrets, keys, certificates" (azure-core-services.md, Security & Identity). That is the exam depth. The ops depth is the constructor.
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
VAULT = "https://ops-vault.vault.azure.net/"
client = SecretClient(vault_url=VAULT, credential=DefaultAzureCredential())
Two arguments. The URL names the vault. The credential names the walker. There is no account key, no shared-access signature, no connection string. A storage-account lesson that starts from AccountKey= is a different door. 08-21 already asked Azure Storage a control-plane question from Terraform. Today the data-plane question is Key Vault, and the door is Entra.
The vault URL is a hostname, not a secret. Logging it is fine. Logging secret.value is the failure this tool exists to prevent.
**Fact three. list_properties_of_secrets is the census. get_secret is the payload.**
list_properties_of_secrets() yields SecretProperties: name, enabled, created, updated, expires_on, content_type, tags. The value is not on the object. You cannot accidentally print a password by listing the vault.
get_secret(name) yields a KeyVaultSecret. .value is the payload. .properties is the metadata you already had. .name and .properties.version identify which generation you fetched.
A nightly inventory that wants expiry dates never calls get_secret. A rotator that must write a new version does. The two methods are different jobs. A wrapper that always fetches the value so the caller can "have it just in case" is the wrapper that leaks into the next log line.
Fact four. The identity that may call is an Entra assignment, not a vault password.
Key Vault used to have access policies written on the vault itself: this object-id may get secrets, that one may list keys. The current door is Azure RBAC on the data plane: Key Vault Secrets User, Key Vault Secrets Officer, Key Vault Certificates Officer, Key Vault Administrator. The assignment is an Entra object (user, group, service principal, or managed identity) plus a role plus a scope (the vault, or a single secret).
08-11 already taught RBAC as the governance answer to "who may do what." Today the same four-tuple lands on a data-plane role. A managed identity on an App Service that holds Key Vault Secrets User on ops-vault can get_secret with no secret in the app settings. That is the whole point of the coin.
The Bootcamp AuthN / AuthZ split is the same split in exam voice (az-900 README, Authentication vs Authorization). Authentication is the Entra token. Authorization is the role assignment. DefaultAzureCredential does the first. The RBAC assignment does the second. A 403 after a successful token is an authorization miss, not a broken chain.
System-assigned managed identity lives and dies with the resource. Delete the App Service and the identity is gone; every role assignment that pointed at it is now an orphan object-id. User-assigned managed identity is a standalone resource you attach to one or many hosts. The identity survives the host. A census Function and a rotator Function can share one user-assigned identity and one Key Vault Secrets Officer assignment. DefaultAzureCredential on a host with both a system-assigned identity and a user-assigned identity needs managed_identity_client_id set, or the walker guesses. Guessing is a 400 from IMDS that looks like a platform outage.
The three Bootcamp nouns have three clients. SecretClient is tonight's census. KeyClient talks to cryptographic keys (create, wrap, unwrap, sign). CertificateClient talks to X.509 material the Bootcamp names under Key Vault (az-900 README, Certificates / X.509 Standard). A tool that imports SecretClient and then asks it for a certificate thumbprint is calling the wrong door. The credential object is the same walker. The client is not.
Brikman's "The Way You Store Secrets" (Ch.6) compares secret stores as a Terraform concern: the state file holds what you wrote. Key Vault is the store the application reads at run time. The two clocks are different. A azurerm_key_vault_secret in state is a value Terraform already saw. Tonight's Python census refuses to see it again.
§III — Mechanism: the walker, the vault, the finding
The walker you construct once
Construct DefaultAzureCredential once per process. Share it across clients. The first get_token walks the chain and caches the winner. A new credential per request re-walks IMDS or re-spawns az account get-access-token and turns a 20-millisecond call into a 400-millisecond one.
Exclude rungs you do not want. A production Function that must never fall through to a developer's leftover CLI session passes exclude_azure_cli_credential=True (and the PowerShell / Azure Developer CLI cousins). A laptop tool that must never silently pick up a managed identity on a borrowed jump box passes exclude_managed_identity_credential=True. The defaults are for "try everything." An ops tool names the rungs it will accept.
The vault you name by URL
The vault name is in the hostname. https://ops-vault.vault.azure.net/ is the public endpoint. A private endpoint changes the resolution, not the client constructor: you still pass that URL; the NIC answers it. A vault in a sovereign cloud changes the suffix (vault.azure.cn, vault.usgovcloudapi.net). The client does not discover the vault. You name it.
A tool that takes --vault-name ops-vault and builds the URL is fine. A tool that takes --connection-string is the old door. Refuse the flag.
The census that does not fetch values
from datetime import datetime, timezone
def inventory(client, now=None):
now = now or datetime.now(timezone.utc)
rows = []
for props in client.list_properties_of_secrets():
expires = props.expires_on
days = None if expires is None else (expires - now).days
rows.append({
"name": props.name,
"enabled": props.enabled,
"expires_on": None if expires is None else expires.isoformat(),
"days_remaining": days,
})
return rows
Every field on that row is metadata. The payload never entered the process. A JSON report of this list can go to stdout, to a ticket, to the 08-02 heartbeat file. Nothing in it is a credential.
The finding that a key walked in
import os
def key_in_environment():
return bool(os.environ.get("AZURE_CLIENT_SECRET"))
If this is true, rung 1 of the chain will win. The token still comes from Entra. The secret still sat in the process environment, in the Function app settings, in the pipeline variable that was marked "secret" and then interpolated into env:. The tool exits 2 (could-not-claim-the-coin) before it lists anything, or it lists and prints a warning line that names the variable, not the value.
Managed identity and workload identity leave this function false. CLI-on-a-laptop leaves it false. Those are the rungs the coin permits.
The 403 that is not a 401
A 401 means the walker found no token. Check the rung list. A Function without a system-assigned identity, a laptop without az login, an environment missing tenant and client, all produce 401.
A 403 means a token arrived and the vault refused the verb. The managed identity exists. The role assignment does not, or it is on the wrong scope, or it is still propagating. Do not rotate a client secret in response to a 403. Do not add AZURE_CLIENT_SECRET to "just get it working." The assignment is the fix.
§IV — Worked example: the expiry census that will not print a value
The job: nightly, against https://ops-vault.vault.azure.net/, list every secret's name, enabled flag, and days until expires_on. Exit 0 if every enabled secret has more than 14 days. Exit 1 if any enabled secret is inside 14 days or already expired. Exit 2 if the walker found no token, or if AZURE_CLIENT_SECRET is set. Never call get_secret. Never interpolate a KeyVaultSecret into a log line.
import os
import sys
from datetime import datetime, timezone
from azure.core.exceptions import ClientAuthenticationError, HttpResponseError
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
VAULT_URL = "https://ops-vault.vault.azure.net/"
WARN_DAYS = 14
def main():
if os.environ.get("AZURE_CLIENT_SECRET"):
print("AZURE_CLIENT_SECRET is set; the chain will walk a key", file=sys.stderr)
return 2
credential = DefaultAzureCredential(
exclude_interactive_browser_credential=True,
)
client = SecretClient(vault_url=VAULT_URL, credential=credential)
now = datetime.now(timezone.utc)
worst = 0
try:
for props in client.list_properties_of_secrets():
if not props.enabled:
print(f"{props.name} disabled")
continue
if props.expires_on is None:
print(f"{props.name} no-expiry")
continue
days = (props.expires_on - now).days
print(f"{props.name} days={days}")
if days < WARN_DAYS:
worst = 1
except ClientAuthenticationError:
print("no token; the walker found no rung", file=sys.stderr)
return 2
except HttpResponseError as exc:
status = getattr(exc, "status_code", None)
print(f"vault refused status={status}", file=sys.stderr)
return 2 if status in (401, None) else 1
return worst
if __name__ == "__main__":
sys.exit(main())
Four disciplines sit in that script.
The environment check is first, before the client exists. A key in the environment is a finding about the runtime, not about the vault.
exclude_interactive_browser_credential=True keeps a headless cron from opening a browser when every other rung fails. Cron has no one to click. A 401 is the honest miss.
The loop reads props.name and props.expires_on. It never touches .value. A future maintainer who changes the loop to get_secret so they can "confirm the secret still decodes" has broken the coin. The test for that regression is a unit test that stubs list_properties_of_secrets and asserts get_secret was not called.
Exit 2 versus exit 1 follows 08-02: 2 means the tool could not run, 1 means the tool ran and the vault is in a bad shape. A supervisor that retries 2 and pages on 1 is reading the right number.
The HttpResponseError branch names status_code. A 403 printed as "vault refused status=403" tells the on-call to look at the role assignment. The same exception printed as repr(exc) can include response bodies. Prefer the number.
§V — Connection to prior lessons
08-20 taught a census that refuses a leftover name. list_managed_instances is to a MIG what list_properties_of_secrets is to a vault: the list that does not start from last night's handle. The leftover in Azure identity is an account key in a config file, or a client secret in app settings.
08-17 taught a signal that must still fire. A Lambda that raised without a PUT left CloudFormation in CREATE_IN_PROGRESS. Today's cousin is a rotator that fetches a value, writes a new version, and then logs the object. The signal that must still fire is the log line that does not carry .value. Silence about the payload is the success.
08-11 taught three ceilings on ARM reads. The identity call has one ceiling that matters more than concurrency: the token cache on the shared DefaultAzureCredential. Construct it once. The 08-11 governor still applies if you fan this census across many vaults; the new fact is that the credential object is shared the way the connector was shared.
08-08 taught a retry budget against Google APIs. A 401 from Key Vault is not a retry. Re-walking the chain will not attach a managed identity that was never assigned. Retry 429 and 503. Treat 401 and 403 as configuration.
§VI — Connection to today's Dev lesson
Today's Dev lesson is __repr__ and field(repr=False). Default dataclass repr prints every field. A KeyVaultSecret interpolated into an f-string, a log line, or an exception message is a payload in a file you did not mean to write.
The ops rule is: never call get_secret in the census. The language rule is: if a value object must exist, its __repr__ must not contain the value. The two rules are one coin. The credential that is not a key is also the print that is not the value.
§VII — Closing
Construct the walker once. Name the vault by URL. List properties. Leave .value on the other side of a method you did not call. Treat AZURE_CLIENT_SECRET as a finding. Read 403 as an assignment miss.
The Bootcamp sentence is short: Key Vault stores secrets, keys, and certificates. The ops sentence is shorter. The process that listed them held no key.
Examine the next log your census writes. If a payload could have appeared there, the coin is already spent.
Related
- Prior arc: the instance that is not a name (2026-08-20)
- Domain hub: Cross-References/domains/01-Earth-DevOps
- Grounding: AZ-900 Bootcamp — Key Vault · Azure Core Services — Security & Identity