Skip to main content
Azure · lesson 17 of 19
Azure intermediate Lesson 17 of 19

Azure Key Vault: Secrets and Versions

Store and read Key Vault secrets with the Python SDK against the Lowkey Vault emulator: every set_secret call creates a new version rather than overwriting, get_secret without a version returns the newest one, and a stale version id raises a 404.

By SkillByExample · Published

azure key-vault secrets

A Key Vault secret name is really a chain of versions: set_secret on an existing name never overwrites, it appends. get_secret without a version argument walks to the newest one. This lesson runs against Lowkey Vault, a third-party local test double (there’s no official Key Vault emulator) — it accepts any bearer token without checking it against Entra ID, and the script below disables TLS verification against its self-signed certificate. Neither is safe outside a local test double; RBAC, managed identity, and DefaultAzureCredential all need a real tenant and aren’t covered here.

Every set_secret Call Is a New Version

import time
import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.filterwarnings("ignore", category=InsecureRequestWarning)

from azure.core.credentials import AccessToken, TokenCredential
from azure.keyvault.secrets import SecretClient


class StubCredential(TokenCredential):
    """Lowkey Vault accepts any bearer token; it never checks it against Entra ID."""

    def get_token(self, *scopes, **kwargs):
        return AccessToken("fake-token", int(time.time()) + 3600)


client = SecretClient(
    vault_url="https://localhost:8443",
    credential=StubCredential(),
    verify_challenge_resource=False,
    connection_verify=False,
)

first = client.set_secret("db-password", "first-value")
time.sleep(1.1)  # two sets in the same second sort ambiguously by created_on
second = client.set_secret("db-password", "second-value")
print("version 1 id:", first.properties.version)
print("version 2 id:", second.properties.version)

versions = list(client.list_properties_of_secret_versions("db-password"))
print("total versions:", len(versions))

latest = client.get_secret("db-password")
print("get_secret (no version):", latest.value)

pinned = client.get_secret("db-password", version=first.properties.version)
print("get_secret (pinned to version 1):", pinned.value)
$ docker run -d --name az-kv-demo nagyesta/lowkey-vault:7.4.0
24866501eececb773ba17d0aaf100df646010a45b4b05dad9f591d12b2bcf317
$ until docker logs az-kv-demo 2>&1 | grep -q 'Started LowkeyVaultApp'; do sleep 0.3; done
$ docker run --rm --network container:az-kv-demo -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-keyvault-secrets==4.12.0 2>&1 | tail -1 && python kv_versions.py"
[notice] To update, run: pip install --upgrade pip
version 1 id: 93ddbfe7e81f47249346ef13a4dbe43c
version 2 id: c8cd7e30eeeb45f2963528f6f4c13baa
total versions: 2
get_secret (no version): second-value
get_secret (pinned to version 1): first-value

Both set_secret calls return a SecretProperties.version id — a vault-generated string, never something to type by hand. get_secret("db-password") with no version argument walked straight to version 2, and list_properties_of_secret_versions shows both versions still exist. The time.sleep(1.1) matters: Lowkey Vault orders versions by created_on, and two writes inside the same second can sort ambiguously — code that assumes “the last item in the list is the newest” is one clock tick away from being wrong. Code that needs a specific version should hold onto the id the write itself returned, as first.properties.version does here, not re-derive it from list order.

A Version That Doesn’t Exist Raises the Same Exception as a Missing Secret

import time
import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.filterwarnings("ignore", category=InsecureRequestWarning)

from azure.core.credentials import AccessToken, TokenCredential
from azure.core.exceptions import ResourceNotFoundError
from azure.keyvault.secrets import SecretClient


class StubCredential(TokenCredential):
    def get_token(self, *scopes, **kwargs):
        return AccessToken("fake-token", int(time.time()) + 3600)


client = SecretClient(
    vault_url="https://localhost:8443",
    credential=StubCredential(),
    verify_challenge_resource=False,
    connection_verify=False,
)

try:
    client.get_secret("db-password", version="0" * 32)
except ResourceNotFoundError as e:
    print("bad version ->", type(e).__name__, e.status_code)

try:
    client.get_secret("never-created")
except ResourceNotFoundError as e:
    print("missing secret ->", type(e).__name__, e.status_code)
$ docker run --rm --network container:az-kv-demo -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-keyvault-secrets==4.12.0 2>&1 | tail -1 && python kv_bad_version.py"
[notice] To update, run: pip install --upgrade pip
bad version -> ResourceNotFoundError 404
missing secret -> ResourceNotFoundError 404

Same exception type, same status code, for two different problems: a real secret with a version id that was never issued, and a secret name that was never created at all. The SDK doesn’t tell these apart in the exception class — only the message differs — so code that needs to distinguish “this secret has never existed” from “this specific version is gone” has to read e.message rather than branch on exception type.

Practice

1. Add a third version and confirm the count becomes 3 without re-running the first two sets.
import time
import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.filterwarnings("ignore", category=InsecureRequestWarning)

from azure.core.credentials import AccessToken, TokenCredential
from azure.keyvault.secrets import SecretClient


class StubCredential(TokenCredential):
    def get_token(self, *scopes, **kwargs):
        return AccessToken("fake-token", int(time.time()) + 3600)


client = SecretClient(
    vault_url="https://localhost:8443",
    credential=StubCredential(),
    verify_challenge_resource=False,
    connection_verify=False,
)

client.set_secret("db-password", "third-value")
versions = list(client.list_properties_of_secret_versions("db-password"))
print("total versions:", len(versions))
$ docker run --rm --network container:az-kv-demo -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-keyvault-secrets==4.12.0 2>&1 | tail -1 && python kv_third_version.py"
[notice] To update, run: pip install --upgrade pip
total versions: 3

The vault from the lesson above is still running (it’s the same az-kv-demo container), and db-password already had two versions — adding a third without restarting anything is the point.

2. Sort the versions list by created_on and fetch the oldest one explicitly, instead of trusting list order.
import time
import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.filterwarnings("ignore", category=InsecureRequestWarning)

from azure.core.credentials import AccessToken, TokenCredential
from azure.keyvault.secrets import SecretClient


class StubCredential(TokenCredential):
    def get_token(self, *scopes, **kwargs):
        return AccessToken("fake-token", int(time.time()) + 3600)


client = SecretClient(
    vault_url="https://localhost:8443",
    credential=StubCredential(),
    verify_challenge_resource=False,
    connection_verify=False,
)

versions = list(client.list_properties_of_secret_versions("db-password"))
oldest = sorted(versions, key=lambda v: v.created_on)[0]
value = client.get_secret("db-password", version=oldest.version).value
print("oldest version value:", value)
$ docker run --rm --network container:az-kv-demo -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-keyvault-secrets==4.12.0 2>&1 | tail -1 && python kv_oldest_version.py"
[notice] To update, run: pip install --upgrade pip
oldest version value: first-value

sorted(..., key=lambda v: v.created_on)[0] is the explicit version of what “the first version” actually means — by creation time, not by whatever order the vault happens to return them in.

3. Set a secret with a content_type and confirm it comes back on the properties the SDK returns.
import time
import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.filterwarnings("ignore", category=InsecureRequestWarning)

from azure.core.credentials import AccessToken, TokenCredential
from azure.keyvault.secrets import SecretClient


class StubCredential(TokenCredential):
    def get_token(self, *scopes, **kwargs):
        return AccessToken("fake-token", int(time.time()) + 3600)


client = SecretClient(
    vault_url="https://localhost:8443",
    credential=StubCredential(),
    verify_challenge_resource=False,
    connection_verify=False,
)

created = client.set_secret(
    "api-key", "sk_test_12345", content_type="application/x-api-key"
)
print("content_type:", created.properties.content_type)

fetched = client.get_secret("api-key")
print("content_type on fetch:", fetched.properties.content_type)
$ docker run --rm --network container:az-kv-demo -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-keyvault-secrets==4.12.0 2>&1 | tail -1 && python kv_content_type.py"
[notice] To update, run: pip install --upgrade pip
content_type: application/x-api-key
content_type on fetch: application/x-api-key

content_type is metadata the vault stores and returns alongside the secret, not something it validates or acts on — Key Vault doesn’t parse or enforce it, the caller does.

4. List every secret name in the vault, not just their versions.
import time
import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.filterwarnings("ignore", category=InsecureRequestWarning)

from azure.core.credentials import AccessToken, TokenCredential
from azure.keyvault.secrets import SecretClient


class StubCredential(TokenCredential):
    def get_token(self, *scopes, **kwargs):
        return AccessToken("fake-token", int(time.time()) + 3600)


client = SecretClient(
    vault_url="https://localhost:8443",
    credential=StubCredential(),
    verify_challenge_resource=False,
    connection_verify=False,
)

names = sorted(s.name for s in client.list_properties_of_secrets())
print("secret names:", names)
$ docker run --rm --network container:az-kv-demo -v "$(pwd)":/app -w /app python:3.14-slim bash -c "pip install -q azure-keyvault-secrets==4.12.0 2>&1 | tail -1 && python kv_list_secrets.py"
[notice] To update, run: pip install --upgrade pip
secret names: ['api-key', 'db-password']

list_properties_of_secrets is one level up from list_properties_of_secret_versions — it enumerates secret names in the vault, one entry per name regardless of how many versions each one has accumulated across this lesson and its practice tasks.

Cleanup

$ docker rm -f az-kv-demo
az-kv-demo

Next: soft-delete and recovery — what actually happens when a secret is deleted, and how to get it back.

Frequently Asked Questions

Does Azure Key Vault overwrite a secret when I set it again?
No. Every set_secret call on an existing secret name creates a brand new version with its own version id; nothing is deleted. get_secret without a version argument returns the newest one, but every version a vault has ever held for that name stays listable (and readable by its own id) until it's purged.
Can I run Azure Key Vault locally without an Azure account?
There's no official Microsoft emulator for Key Vault. Lowkey Vault is a third-party, open-source test double that speaks the same REST API the SDK calls, which is enough to learn secret CRUD and versioning. It doesn't model Key Vault's real authentication (Entra ID, RBAC) or its cryptographic key operations exactly, so treat it as a stand-in for the data-plane shape, not a substitute for testing against a real vault before production.
What exception does get_secret raise for a version that doesn't exist?
azure.core.exceptions.ResourceNotFoundError, with a 404 status_code, the same exception a request for a secret name that was never created raises. The SDK doesn't distinguish 'wrong version of a real secret' from 'no such secret' in the exception type — you have to read the message if you need to tell them apart.
How do I read a specific old version of a secret instead of the latest?
Pass its version id string to get_secret(name, version=...). The version id to use should come from a real SDK call — list_properties_of_secret_versions or the SecretProperties.version returned by the set_secret call that created it — never typed in by hand, since it's an opaque, SDK-generated id.