Autenticando no Azure Key Vault e lendo segredos com Azure CLI, Azure Pipelines e Python

Autenticando no Azure Key Vault e lendo segredos com Azure CLI, Azure Pipelines e Python

30 de outubro de 2026

O Azure Key Vault é o serviço gerenciado da Microsoft para armazenar e controlar o acesso a segredos, chaves criptográficas e certificados. Em vez de espalhar connection strings, API Keys e senhas por arquivos .env, variáveis de pipeline ou, pior, pelo próprio código, centralizamos tudo em um único cofre, com controle de acesso pelo Microsoft Entra ID, auditoria e versionamento de cada segredo. A pergunta que sempre aparece depois disso é a mesma: quem pode ler esses segredos, e como essa identidade se autentica no cofre?

Para responder isso na prática, montamos três exemplos independentes, cada um usando uma identidade diferente para chegar ao mesmo cofre. O primeiro usa o Azure CLI com a identidade humana do próprio desenvolvedor, através de um login interativo. O segundo usa uma pipeline do Azure DevOps autenticando por uma Service Connection com Workload Identity Federation, sem nenhum client secret armazenado. O terceiro é uma Web API em Python com FastAPI, hospedada no Azure App Service, que se autentica como uma App Registration usando client secret.

Cada cenário tem seu lugar. O Azure CLI é o caminho natural para quem opera o ambiente pelo terminal, a pipeline atende a automação de CI/CD, e a Web API representa a aplicação lendo segredos em tempo de execução. Os três exemplos estão no repositório azure-keyvault-samples, cada um em sua própria pasta, com README, scripts e testes. Em todos eles usamos apenas segredos fictícios, como demo-api-key com o valor fake-api-key-123, porque em alguns momentos vamos de fato exibir os valores.

Antes de qualquer código, existe um conceito que faz toda a diferença na hora de configurar permissões: a separação entre control plane e data plane. O control plane é a gestão do recurso em si, como criar o cofre, mudar a rede ou apagar o recurso. O data plane é o conteúdo, ou seja, ler e gravar segredos. Uma identidade com Owner ou Contributor no cofre administra o recurso, mas não consegue ler o valor de nenhum segredo, e esse é o erro de permissão mais comum quando alguém começa a usar o Key Vault.

Para que as permissões de data plane funcionem com Azure Role-Based Access Control, o cofre precisa usar o modelo de permissão Azure RBAC e não o modelo legado de Vault access policy. Com access policies, qualquer role assignment que fizermos simplesmente não tem efeito no acesso aos segredos. Nos três exemplos usamos duas roles nativas: Key Vault Secrets Officer, que lê, cria e atualiza segredos, e Key Vault Secrets User, que apenas lê os valores. Sempre com escopo no próprio cofre, seguindo o princípio do menor privilégio descrito nas boas práticas de RBAC.

O primeiro passo é definir algumas variáveis de ambiente que vamos reutilizar nos comandos. Na nossa POC, o resource group é rg-poc-kv, a região é eastus e o cofre se chama kv-poc-lkaa7sd. Lembrando que o nome do Key Vault precisa ser globalmente único, com 3 a 24 caracteres.

RESOURCE_GROUP="rg-poc-kv"
LOCATION="eastus"
KEYVAULT_NAME="kv-poc-lkaa7sd"
Terminal com as variáveis RESOURCE_GROUP, LOCATION e KEYVAULT_NAME definidas para a POC

Com as variáveis prontas, criamos o resource group e o Key Vault já com o RBAC habilitado através do parâmetro --enable-rbac-authorization. O último comando confirma que o modelo de permissão está correto e precisa retornar true. Se preferir o portal, o mesmo resultado sai do quickstart de criação pelo portal, selecionando Azure role-based access control na aba Access configuration.

az group create --name "$RESOURCE_GROUP" --location "$LOCATION"

az keyvault create \
  --name "$KEYVAULT_NAME" \
  --resource-group "$RESOURCE_GROUP" \
  --location "$LOCATION" \
  --enable-rbac-authorization true

# Must print true
az keyvault show --name "$KEYVAULT_NAME" --query properties.enableRbacAuthorization
Resource group rg-poc-kv no portal do Azure com o Key Vault kv-poc-lkaa7sd criado

O próximo passo é dar ao nosso usuário a role Key Vault Secrets Officer no cofre. Ela é necessária tanto para o script do Azure CLI ler os segredos quanto para criarmos os segredos de demonstração. O comando usa o Object ID do usuário logado e o ID do cofre como escopo, conforme a referência do az role assignment.

KEYVAULT_ID=$(az keyvault show --name "$KEYVAULT_NAME" --query id --output tsv)

az role assignment create \
  --role "Key Vault Secrets Officer" \
  --assignee-object-id "$(az ad signed-in-user show --query id --output tsv)" \
  --assignee-principal-type User \
  --scope "$KEYVAULT_ID"

No portal, a atribuição aparece em Access control (IAM) do cofre, na aba Role assignments. Vale lembrar que a propagação de uma role assignment pode levar alguns minutos, então um Forbidden logo depois de atribuir a role costuma ser só questão de esperar.

Access control (IAM) do Key Vault mostrando o usuário com a role Key Vault Secrets Officer

Com a permissão aplicada, criamos dois segredos fictícios usando o az keyvault secret set. São esses valores que vamos ler nos três exemplos.

az keyvault secret set --vault-name "$KEYVAULT_NAME" --name "demo-api-key"     --value "fake-api-key-123"
az keyvault secret set --vault-name "$KEYVAULT_NAME" --name "demo-db-password" --value "fake-password-456"

O cofre está pronto, então vamos ao primeiro cenário: ler os segredos com a identidade humana pelo Azure CLI. Na pasta azure-cli do repositório está o script read-secrets.sh, que faz o login interativo, permite escolher a subscription, lista os segredos e lê cada um. Por padrão, ele mostra apenas o nome e o status de leitura, e os valores só aparecem com uma flag explícita.

O script começa validando os argumentos antes de qualquer chamada ao Azure, e em seguida cuida da autenticação:

[[ -n "$VAULT_NAME" ]] || { usage >&2; fail "--vault-name is required"; }
[[ "$VAULT_NAME" =~ ^[A-Za-z0-9-]{3,24}$ ]] || fail "Invalid Key Vault name: '$VAULT_NAME' (3-24 letters, digits, hyphens)"
command -v az >/dev/null 2>&1 || fail "Azure CLI (az) is not installed: https://learn.microsoft.com/cli/azure/install-azure-cli"

# ---------------------------------------------------------------------------
# 1. Interactive authentication (human identity)
# ---------------------------------------------------------------------------
if [[ "$FORCE_LOGIN" == true ]] || ! az account show --output none 2>/dev/null; then
  echo "Signing in to Azure (interactive)..."
  if [[ -n "$TENANT" ]]; then
    az login --tenant "$TENANT" --output none
  else
    az login --output none
  fi
fi
  • Validação: o nome do cofre é obrigatório e precisa seguir o formato aceito pelo Key Vault, e o script falha cedo se o Azure CLI não estiver instalado.
  • Autenticação: o az login só é executado quando não existe uma sessão ativa ou quando passamos --login, e o parâmetro --tenant permite forçar o login em um tenant específico.

Depois do login, o script escolhe a subscription. Se passarmos --subscription, ele usa esse valor direto. Caso contrário, monta uma lista numerada com as subscriptions habilitadas da conta e marca a atual como padrão, aplicando a escolha com az account set, como descrito em gerenciar subscriptions com o Azure CLI.

A parte central do script é a leitura dos segredos:

if ! SECRET_LIST=$(az keyvault secret list \
  --vault-name "$VAULT_NAME" \
  --query "[].[name, attributes.enabled]" \
  --output tsv); then
  fail "Unable to list secrets. Check the vault name, network access and the 'Key Vault Secrets Officer' role."
fi

while IFS=$'\t' read -r SECRET_NAME SECRET_ENABLED; do
  [[ -z "$SECRET_NAME" ]] && continue
  TOTAL=$((TOTAL + 1))

  STATUS="READ SUCCESS"
  SECRET_VALUE=""
  if [[ "$SECRET_ENABLED" == "false" ]]; then
    STATUS="DISABLED"
  elif ! SECRET_VALUE=$(az keyvault secret show \
      --vault-name "$VAULT_NAME" \
      --name "$SECRET_NAME" \
      --query value \
      --output tsv 2>/dev/null </dev/null); then
    STATUS="READ FAILED"
    FAILED=$((FAILED + 1))
  fi

  if [[ "$SHOW_VALUES" == true ]]; then
    printf "%-40s %-14s %s\n" "$SECRET_NAME" "$STATUS" "$SECRET_VALUE"
  else
    printf "%-40s %s\n" "$SECRET_NAME" "$STATUS"
  fi
done <<< "$SECRET_LIST"

unset SECRET_VALUE

echo
echo "Secrets: $TOTAL | Failed: $FAILED"
[[ "$FAILED" -eq 0 ]] || exit 1
  • az keyvault secret list: retorna o nome e o atributo enabled de cada segredo, e se a listagem falhar o script já indica as causas prováveis, como nome errado, rede ou falta de role.
  • az keyvault secret show: lê o valor de cada segredo habilitado, enquanto segredos desabilitados aparecem como DISABLED e não são lidos.
  • Saída: sem --show-values o script imprime somente nome e status, com --show-values inclui o valor, e no final retorna código 1 se alguma leitura falhou.

Para executar, damos permissão de execução ao script e informamos o nome do cofre e a subscription.

cd azure-cli
chmod +x read-secrets.sh

./read-secrets.sh --vault-name "kv-poc-lkaa7sd" --subscription "<subscription-id>"

A saída mostra a identidade logada, o cofre consultado e cada segredo com READ SUCCESS. Repare que nenhum valor foi impresso, mas o script de fato leu cada um deles, o que comprova que a nossa identidade tem acesso de data plane.

Execução do read-secrets.sh mostrando a identidade logada e os segredos demo-api-key e demo-db-password com READ SUCCESS, sem valores

Agora executamos o mesmo comando adicionando --show-values. O script imprime um aviso deixando claro que os valores vão para o terminal e devem ser usados somente com segredos fictícios, e em seguida a tabela com os valores.

./read-secrets.sh --vault-name "kv-poc-lkaa7sd" --subscription "<subscription-id>" --show-values
Execução com --show-values exibindo o aviso, a tabela com os valores fictícios e o resumo Secrets: 2 | Failed: 0

O segundo cenário tira o humano da equação e coloca uma pipeline do Azure Pipelines para ler os mesmos segredos. A pipeline se autentica no Azure através de uma Azure Resource Manager Service Connection usando Workload Identity Federation. Nesse modelo, o Azure DevOps emite um token OIDC de curta duração para a execução da pipeline, e o Microsoft Entra ID troca esse token por um access token. Não existe client secret para guardar, vazar ou rotacionar.

Criamos uma Service Connection dedicada para essa pipeline, sem reaproveitar identidades de outras aplicações. Em Project settings, na seção Service connections, selecionamos New service connection e o tipo Azure Resource Manager.

Tela New Azure service connection com a opção Azure Resource Manager selecionada

Na configuração, escolhemos o Identity type App registration (automatic) e a Credential Workload identity federation, ambos marcados como recomendados pelo próprio Azure DevOps. No escopo, selecionamos Subscription, a subscription da POC e o resource group rg-poc-kv, e demos o nome sc-keyvault-reader à conexão.

O ideal é deixar desmarcada a opção Grant access permission to all pipelines. Assim, nenhuma outra pipeline do projeto consegue usar essa conexão, e autorizamos apenas a nossa pipeline no primeiro run, clicando em Permit. Essa recomendação faz parte das orientações de segurança do Azure Pipelines.

Existe um detalhe importante na opção automática: o Azure DevOps cria a App Registration e atribui a ela a role Contributor no escopo escolhido. Para a nossa pipeline isso é permissão demais, e ao mesmo tempo insuficiente, porque Contributor não lê segredos. Por isso, atribuímos Key Vault Secrets User no cofre e removemos a role Contributor. O Client ID da identidade aparece na própria Service Connection, na opção Manage App registration.

SC_CLIENT_ID="<service-connection-client-id>"
KEYVAULT_ID=$(az keyvault show --name "$KEYVAULT_NAME" --query id --output tsv)

az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee-object-id "$(az ad sp show --id "$SC_CLIENT_ID" --query id --output tsv)" \
  --assignee-principal-type ServicePrincipal \
  --scope "$KEYVAULT_ID"

Em seguida, listamos todas as atribuições dessa identidade e removemos a Contributor criada automaticamente. No final, a identidade da Service Connection deve ter somente Key Vault Secrets User no cofre.

az role assignment list --assignee "$SC_CLIENT_ID" --all --output table
az role assignment delete --assignee "$SC_CLIENT_ID" --role "Contributor" --scope "<scope-from-the-list>"

Com a identidade ajustada, vamos ao YAML da pipeline, que está em azure-pipelines/azure-pipelines.yml e segue o schema de YAML do Azure Pipelines:

trigger: none

pool:
  vmImage: 'ubuntu-latest'

variables:
  azureServiceConnection: '<service-connection-name>'
  keyVaultName: '<keyvault-name>'

steps:
  - task: AzureCLI@2
    displayName: 'Read Azure Key Vault Secrets'
    inputs:
      azureSubscription: '$(azureServiceConnection)'
      scriptType: 'bash'
      scriptLocation: 'inlineScript'
      inlineScript: |
        set -euo pipefail

        echo "Reading secrets from $(keyVaultName)"

        SECRET_NAMES=$(az keyvault secret list \
          --vault-name "$(keyVaultName)" \
          --query "[?attributes.enabled].name" \
          --output tsv)

        if [ -z "$SECRET_NAMES" ]; then
          echo "No enabled secrets found."
          exit 0
        fi

        FAILED=0
        while IFS= read -r SECRET_NAME; do
          [ -z "$SECRET_NAME" ] && continue

          if SECRET_VALUE=$(az keyvault secret show \
            --vault-name "$(keyVaultName)" \
            --name "$SECRET_NAME" \
            --query value \
            --output tsv < /dev/null); then
            echo "$SECRET_NAME: READ SUCCESS"
          else
            echo "##vso[task.logissue type=error]$SECRET_NAME: READ FAILED"
            FAILED=$((FAILED + 1))
          fi
          unset SECRET_VALUE
        done <<< "$SECRET_NAMES"

        if [ "$FAILED" -gt 0 ]; then
          echo "$FAILED secret(s) could not be read."
          exit 1
        fi
  • trigger: a pipeline roda apenas de forma manual, já que o objetivo é provar a autenticação e não reagir a commits.
  • variables: azureServiceConnection e keyVaultName são os únicos valores que precisamos trocar, no nosso caso sc-keyvault-reader e kv-poc-lkaa7sd.
  • AzureCLI@2: a task AzureCLI@2 usa a Service Connection informada em azureSubscription para fazer o login federado antes de executar o script, sem nenhuma credencial no YAML.
  • inlineScript: lista apenas os segredos habilitados, lê cada um e registra somente READ SUCCESS. Em caso de falha, usa o comando de log ##vso[task.logissue type=error] e encerra o job com erro.
  • unset SECRET_VALUE: o valor fica em memória apenas durante a leitura e nunca é escrito no log.

Diferente do script do Azure CLI, a pipeline não tem uma opção para exibir os valores, e isso é intencional. Os logs da pipeline são persistidos e ficam visíveis para qualquer pessoa com permissão de leitura no projeto. Além disso, o Azure DevOps só mascara valores que ele sabe que são secretos, e um valor lido dentro de um script não está registrado como segredo, então ele apareceria em texto puro. A orientação oficial em proteger segredos no Azure Pipelines é nunca fazer echo de segredos no log.

Quando a pipeline precisa usar os segredos de fato, e não só provar o acesso, o caminho natural é a task AzureKeyVault@2 ou um variable group vinculado ao cofre. Nesses casos o Azure DevOps registra os valores como secret variables e aplica o mascaramento automaticamente, como mostra o guia de uso do Key Vault em pipelines YAML.

Com o YAML ajustado e commitado no repositório, criamos a pipeline em Pipelines, New pipeline, escolhendo onde está o código.

Tela New pipeline com a pergunta Where is your code? e as opções de repositório

Depois de selecionar o repositório, escolhemos Existing Azure Pipelines YAML file e apontamos para o caminho /azure-pipelines/azure-pipelines.yml na branch main.

Opção Existing Azure Pipelines YAML file com a branch main e o caminho /azure-pipelines/azure-pipelines.yml

Ao executar, o log da task Read Azure Key Vault Secrets mostra exatamente o que acontece por baixo. A AzureCLI@2 executa az login --service-principal com --federated-token, ou seja, o login é feito com o token OIDC da execução, e o usuário aparece com o tipo servicePrincipal. Logo depois, o script lê os dois segredos e registra READ SUCCESS para cada um, sem nenhum valor no log.

Um teste interessante para validar o modelo de permissão é o negativo: removemos temporariamente a role Key Vault Secrets User da identidade, esperamos alguns minutos e executamos de novo. A listagem precisa falhar com Forbidden, provando que é essa role, e nenhuma outra, que libera o acesso. Depois, basta restaurar a atribuição.

O terceiro cenário é uma aplicação lendo o cofre em runtime. Criamos uma Web API em Python com FastAPI, hospedada no Azure App Service para Linux, que se autentica no Key Vault como uma App Registration usando client secret. Esse é o fluxo OAuth 2.0 client credentials: a aplicação apresenta tenant, client ID e client secret ao Entra ID, recebe um token de aplicação e usa esse token para chamar o cofre.

O primeiro passo desse cenário é registrar uma aplicação no Microsoft Entra ID exclusiva para essa API. Criamos a App Registration app-keyvault-fastapi como single tenant e sem redirect URI, geramos o service principal correspondente e criamos um client secret. O valor do client secret aparece apenas uma vez, então precisa ser copiado na hora. A diferença entre o objeto de aplicação e o service principal está bem explicada em application and service principal objects.

APP_CLIENT_ID=$(az ad app create --display-name "app-keyvault-fastapi" --sign-in-audience AzureADMyOrg --query appId --output tsv)
az ad sp create --id "$APP_CLIENT_ID"

# Prints the client secret once. Store it safely.
az ad app credential reset --id "$APP_CLIENT_ID" --display-name "keyvault-fastapi" --years 1 --query password --output tsv

az account show --query tenantId --output tsv

Assim como na pipeline, a App Registration da API recebe apenas Key Vault Secrets User no cofre, porque ela só precisa ler os segredos.

az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee-object-id "$(az ad sp show --id "$APP_CLIENT_ID" --query id --output tsv)" \
  --assignee-principal-type ServicePrincipal \
  --scope "$KEYVAULT_ID"

Para hospedar a API, criamos um App Service Plan Linux e um App Service com runtime Python. Na POC, o plano se chama asp-poc-kv e o App Service app-poc-kv-lkaa7sd. O plano B1 é suficiente para o exemplo.

az appservice plan create --name "asp-poc-kv" --resource-group "$RESOURCE_GROUP" --is-linux --sku B1
az webapp create --name "app-poc-kv-lkaa7sd" --resource-group "$RESOURCE_GROUP" --plan "asp-poc-kv" --runtime "PYTHON:3.15"

Passando para o código, o projeto está na pasta python-web-api e é dividido em três arquivos principais. A configuração fica no config.py, que carrega as variáveis de ambiente localmente a partir de um .env e, no App Service, a partir das App settings:

TRUE_VALUES = {"1", "true", "yes", "on"}


class ConfigurationError(Exception):
    """Raised when required settings are missing or invalid."""


@dataclass(frozen=True)
class Settings:
    tenant_id: Optional[str]
    client_id: Optional[str]
    client_secret: Optional[str]
    keyvault_url: Optional[str]
    show_values_enabled: bool = False

    def validate_keyvault_settings(self) -> None:
        """Ensure everything needed to reach Azure Key Vault is present."""
        missing = [
            name
            for name, value in (
                ("AZURE_TENANT_ID", self.tenant_id),
                ("AZURE_CLIENT_ID", self.client_id),
                ("AZURE_CLIENT_SECRET", self.client_secret),
                ("AZURE_KEYVAULT_URL", self.keyvault_url),
            )
            if not value
        ]
        if missing:
            raise ConfigurationError(f"Missing required settings: {', '.join(missing)}")

        if not self.keyvault_url.startswith("https://"):
            raise ConfigurationError("AZURE_KEYVAULT_URL must start with https://")


def _get(env: Mapping[str, str], name: str) -> Optional[str]:
    value = env.get(name, "").strip()
    return value or None


def load_settings(env: Optional[Mapping[str, str]] = None) -> Settings:
    """Build settings from the given mapping, or from the process environment."""
    if env is None:
        load_dotenv()
        env = os.environ

    return Settings(
        tenant_id=_get(env, "AZURE_TENANT_ID"),
        client_id=_get(env, "AZURE_CLIENT_ID"),
        client_secret=_get(env, "AZURE_CLIENT_SECRET"),
        keyvault_url=_get(env, "AZURE_KEYVAULT_URL"),
        show_values_enabled=(_get(env, "SHOW_SECRET_VALUES_ENABLED") or "").lower() in TRUE_VALUES,
    )

Settings

  • Dataclass imutável que guarda tenant, client ID, client secret, URL do cofre e a flag show_values_enabled, que controla se a API pode devolver valores.

validate_keyvault_settings()

  • Lança ConfigurationError listando todas as variáveis que faltam e garante que a URL do cofre use https://.

load_settings()

  • Monta o Settings a partir de um dicionário recebido ou, quando nada é passado, das variáveis de ambiente do processo, carregando antes o .env com python-dotenv. Receber o dicionário como parâmetro facilita os testes.

O acesso ao cofre fica isolado no keyvault_service.py, que usa o SecretClient da biblioteca azure-keyvault-secrets junto com o ClientSecretCredential da azure-identity:

STATUS_READ_SUCCESS = "read_success"
STATUS_READ_FAILED = "read_failed"
STATUS_DISABLED = "disabled"


class KeyVaultAccessError(Exception):
    """Raised when the vault cannot be reached or its secrets cannot be listed."""


@dataclass(frozen=True)
class SecretReadResult:
    name: str
    status: str
    value: Optional[str] = None
    error: Optional[str] = None


class KeyVaultService:
    def __init__(self, client: SecretClient) -> None:
        self._client = client

    @property
    def vault_url(self) -> str:
        return self._client.vault_url

    @classmethod
    def from_settings(cls, settings: Settings) -> "KeyVaultService":
        settings.validate_keyvault_settings()
        credential = ClientSecretCredential(
            tenant_id=settings.tenant_id,
            client_id=settings.client_id,
            client_secret=settings.client_secret,
        )
        return cls(SecretClient(vault_url=settings.keyvault_url, credential=credential))

    def read_secrets(self, include_values: bool = False) -> List[SecretReadResult]:
        """List every secret and read its current value from the vault.

        Values are always read (to prove access works) but only returned when
        `include_values` is True.
        """
        try:
            properties = list(self._client.list_properties_of_secrets())
        except AzureError as exc:
            raise KeyVaultAccessError(f"Unable to list secrets: {type(exc).__name__}") from exc

        results = []
        for secret_properties in sorted(properties, key=lambda p: p.name):
            results.append(self._read_secret(secret_properties, include_values))
        return results

    def _read_secret(self, secret_properties, include_values: bool) -> SecretReadResult:
        name = secret_properties.name
        if secret_properties.enabled is False:
            return SecretReadResult(name=name, status=STATUS_DISABLED)

        try:
            secret = self._client.get_secret(name)
        except AzureError as exc:
            return SecretReadResult(name=name, status=STATUS_READ_FAILED, error=type(exc).__name__)

        return SecretReadResult(
            name=name,
            status=STATUS_READ_SUCCESS,
            value=secret.value if include_values else None,
        )

SecretReadResult

  • Representa o resultado da leitura de um segredo, com o nome, o status (read_success, read_failed ou disabled), o valor opcional e o tipo do erro quando a leitura falha.

from_settings()

  • Valida as configurações, cria o ClientSecretCredential com tenant, client ID e client secret e devolve o serviço com um SecretClient apontando para o cofre. O token obtido no Entra ID fica em cache dentro do próprio SDK.

read_secrets()

  • Lista as propriedades de todos os segredos com list_properties_of_secrets(), ordena por nome e lê cada um. Se a própria listagem falhar, por credencial inválida ou falta de permissão, lança KeyVaultAccessError com o tipo da exceção, sem expor detalhes internos.

_read_secret()

  • Ignora segredos desabilitados, lê o valor com get_secret() e só devolve esse valor quando include_values é verdadeiro. A leitura acontece sempre, mesmo sem devolver o valor, para provar que o acesso funciona, e uma falha em um segredo não interrompe a leitura dos outros.

Por fim, o main.py expõe os endpoints com FastAPI e conecta tudo usando dependency injection:

app = FastAPI(
    title="Azure Key Vault Python Web API",
    description="Reads Azure Key Vault secrets using an App Registration (client secret).",
    version="1.0.0",
)

class HealthResponse(BaseModel):
    status: str


class SecretItem(BaseModel):
    name: str
    status: str
    value: Optional[str] = None
    error: Optional[str] = None


class SecretsResponse(BaseModel):
    vault_url: str
    count: int
    values_included: bool
    secrets: List[SecretItem]


@lru_cache
def get_settings() -> Settings:
    return load_settings()


@lru_cache
def _build_keyvault_service() -> KeyVaultService:
    return KeyVaultService.from_settings(get_settings())


def get_keyvault_service() -> KeyVaultService:
    try:
        return _build_keyvault_service()
    except ConfigurationError as exc:
        raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc


def authorize_secret_values(settings: Settings) -> None:
    """Allow value display only when explicitly enabled. No caller authentication."""
    if not settings.show_values_enabled:
        raise HTTPException(
            status.HTTP_403_FORBIDDEN,
            detail="Showing secret values is disabled. Set SHOW_SECRET_VALUES_ENABLED=true to allow it.",
        )


@app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
    return HealthResponse(status="ok")


@app.get("/secret", response_model=SecretsResponse, response_model_exclude_none=True)
def read_secrets(
    response: Response,
    include_values: bool = Query(
        False, description="Return secret values. Requires SHOW_SECRET_VALUES_ENABLED=true."
    ),
    settings: Settings = Depends(get_settings),
    service: KeyVaultService = Depends(get_keyvault_service),
) -> SecretsResponse:
    response.headers["Cache-Control"] = "no-store"

    if include_values:
        authorize_secret_values(settings)

    try:
        results = service.read_secrets(include_values=include_values)
    except KeyVaultAccessError as exc:
        raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc

    return SecretsResponse(
        vault_url=service.vault_url,
        count=len(results),
        values_included=include_values,
        secrets=[SecretItem(**result.__dict__) for result in results],
    )

get_settings() e _build_keyvault_service()

  • Ambas usam lru_cache, então as configurações e o SecretClient são criados uma única vez por processo e reaproveitados entre as requisições.

get_keyvault_service()

  • Dependência injetada no endpoint que transforma uma configuração ausente em uma resposta 500 com a lista das variáveis que faltam.

authorize_secret_values()

  • Libera a exibição de valores somente quando SHOW_SECRET_VALUES_ENABLED está ligado, caso contrário responde 403 Forbidden.

health()

  • Endpoint de liveness que não chama o Azure, útil para saber se a aplicação subiu independentemente do cofre.

read_secrets()

  • Endpoint GET /secret, que consulta o cofre a cada requisição e devolve nomes e status. Com include_values=true também devolve os valores, se a flag permitir. Toda resposta leva Cache-Control: no-store, e uma falha ao listar o cofre vira 502 Bad Gateway.

Um ponto precisa ficar bem claro: a API não tem autenticação de chamador. Quando a flag SHOW_SECRET_VALUES_ENABLED está ligada, qualquer pessoa que alcance a URL consegue ler todos os valores do cofre. Por isso ela existe apenas para a demonstração com segredos fictícios, e deve ser desligada logo depois.

Para rodar localmente, criamos um ambiente virtual, instalamos as dependências e copiamos o .env.example para .env, preenchendo tenant, client ID, client secret e a URL do cofre.

cd python-web-api
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements-dev.txt

cp .env.example .env
python -m uvicorn main:app --reload

O .env.example mostra exatamente o que a aplicação espera:

AZURE_TENANT_ID=<tenant-id>
AZURE_CLIENT_ID=<client-id>
AZURE_CLIENT_SECRET=<client-secret>
AZURE_KEYVAULT_URL=https://<keyvault-name>.vault.azure.net/

SHOW_SECRET_VALUES_ENABLED=false

O projeto também tem uma suíte de testes com pytest. Os testes fazem mock do SecretClient e das dependências do FastAPI, então não chamam o Azure e não precisam de credenciais. Eles cobrem o endpoint de health, a resposta sem valores, a leitura do cofre a cada requisição, o 403 com a flag desligada, o 502 quando o cofre falha, segredos desabilitados, falha de leitura em um segredo específico e a validação das configurações.

python -m pytest

Com a aplicação rodando, abrimos o Swagger gerado pelo FastAPI em http://127.0.0.1:8000/docs e executamos GET /secret com include_values=true. Como a flag estava ligada no .env local, a resposta 200 traz a URL do cofre, a quantidade de segredos e cada um com status read_success e seu valor fictício.

Swagger local em 127.0.0.1:8000/docs com GET /secret?include_values=true retornando 200 e os valores fictícios

Para publicar no Azure, primeiro configuramos as mesmas variáveis como App settings do App Service. No portal, elas ficam em Settings, Environment variables, na aba App settings.

az webapp config appsettings set \
  --resource-group "$RESOURCE_GROUP" \
  --name "app-poc-kv-lkaa7sd" \
  --settings \
    AZURE_TENANT_ID="<tenant-id>" \
    AZURE_CLIENT_ID="<client-id>" \
    AZURE_CLIENT_SECRET="<client-secret>" \
    AZURE_KEYVAULT_URL="https://kv-poc-lkaa7sd.vault.azure.net/" \
    SHOW_SECRET_VALUES_ENABLED="false"

O deploy é feito manualmente, a partir da máquina do desenvolvedor, com o script deploy.sh. Ele usa a sessão atual do az login, sem Docker e sem pipeline, através de ZIP Deploy com build remoto. O usuário que executa o deploy precisa da role Website Contributor no App Service, que cobre as App settings, o startup command, a publicação do pacote e o restart.

O script começa validando o ambiente: confere se az, zip e unzip estão instalados, se existe uma sessão ativa, se o App Service existe e se ele é Linux com runtime Python. Em seguida, decide o startup command de acordo com a versão do Python, seguindo o guia de configuração de apps Python no App Service:

DEFAULT_STARTUP_COMMAND="python -m uvicorn main:app --host 0.0.0.0 --port 8000"
APP_FILES=(main.py config.py keyvault_service.py requirements.txt)

if [[ -z "$STARTUP_COMMAND" ]] && (( PYTHON_MAJOR == 3 && PYTHON_MINOR <= 13 )); then
  STARTUP_COMMAND="$DEFAULT_STARTUP_COMMAND"
fi

A partir do Python 3.14, o App Service detecta e executa aplicações FastAPI automaticamente, então o script não define nenhum comando. Até o Python 3.13, o startup command é obrigatório, e o script configura o Uvicorn na porta 8000. Depois disso vem a parte que efetivamente publica a aplicação:

az webapp config appsettings set \
  --resource-group "$RESOURCE_GROUP" \
  --name "$APP_SERVICE_NAME" \
  --settings SCM_DO_BUILD_DURING_DEPLOYMENT=true \
  --output none

(cd "$SCRIPT_DIR" && zip -q "$ZIP_PATH" "${APP_FILES[@]}")

ZIP_CONTENT=$(unzip -Z1 "$ZIP_PATH")
for required in main.py requirements.txt; do
  echo "$ZIP_CONTENT" | grep -qx "$required" || fail "Package is missing $required at the root"
done

az webapp deploy \
  --resource-group "$RESOURCE_GROUP" \
  --name "$APP_SERVICE_NAME" \
  --src-path "$ZIP_PATH" \
  --type zip

az webapp restart \
  --resource-group "$RESOURCE_GROUP" \
  --name "$APP_SERVICE_NAME"
  • SCM_DO_BUILD_DURING_DEPLOYMENT: habilita o build remoto, fazendo o Oryx executar pip install -r requirements.txt dentro do App Service.
  • zip: o pacote contém apenas os quatro arquivos da aplicação, então .env, .venv, testes e caches nunca são enviados, e o script confere se main.py e requirements.txt estão na raiz do ZIP.
  • az webapp deploy e az webapp restart: publicam o pacote via ZIP Deploy e reiniciam a aplicação.

Para executar o deploy, basta informar o resource group e o nome do App Service. O script mostra a conta, o runtime e o startup command antes de pedir confirmação.

az login
cd python-web-api
./deploy.sh --resource-group "rg-poc-kv" --name "app-poc-kv-lkaa7sd"

Com a aplicação publicada, abrimos o Swagger em https://app-poc-kv-lkaa7sd.azurewebsites.net/docs e repetimos o mesmo teste. Para essa demonstração, ligamos temporariamente SHOW_SECRET_VALUES_ENABLED nas App settings, e a resposta é idêntica à execução local, agora vinda do App Service autenticando no cofre com a App Registration.

Swagger no App Service app-poc-kv-lkaa7sd.azurewebsites.net com GET /secret?include_values=true retornando 200 e os valores fictícios

Como a API lê o cofre a cada requisição, um segredo criado agora aparece já na próxima chamada, sem precisar reiniciar ou republicar a aplicação. Logo depois da demonstração, voltamos a flag para false.

Vale fazer uma observação sobre produção. Guardar um client secret nas App settings é exatamente o que este exemplo quer demonstrar, a autenticação por App Registration, mas ele traz a responsabilidade de proteger e rotacionar essa credencial. Para aplicações hospedadas no próprio Azure, o caminho recomendado é usar Managed Identity no App Service, em que não existe credencial armazenada. Nesse caso, a troca no código é pequena: o ClientSecretCredential dá lugar ao DefaultAzureCredential, e a role Key Vault Secrets User passa a ser atribuída à identidade do App Service.

Olhando os três cenários lado a lado, o padrão de segurança se repete. Cada consumidor tem sua própria identidade, recebe somente a role de data plane que precisa e com escopo no cofre, e os valores nunca são escritos em lugares persistentes como logs de pipeline. A diferença está no tipo de credencial: a sessão interativa do usuário no Azure CLI, o token federado sem segredo na pipeline e o client secret na Web API. Sempre que possível, prefira os modelos sem segredo, como Workload Identity Federation e Managed Identity.

Para levar esse cofre a um ambiente real, as boas práticas do Key Vault recomendam um cofre por aplicação e por ambiente, restrição de rede com firewall ou Private Link, logging habilitado para auditar quem leu cada segredo e o soft delete para recuperar segredos apagados por engano. Esse mesmo soft delete explica um detalhe da limpeza do ambiente: depois de apagar o resource group, o nome do cofre continua reservado até que ele seja purgado.

az group delete --name "$RESOURCE_GROUP" --yes
az ad app delete --id "<client-id>"
az keyvault purge --name "$KEYVAULT_NAME"

Para completar a limpeza, também apagamos a Service Connection em Project settings, Service connections, junto com a App Registration criada automaticamente para ela no Microsoft Entra ID.

Você já pode baixar o projeto por esse link, e não esquece de me seguir no LinkedIn!
Até a próxima, abraços!

Confira mais:

Fique por dentro das novidades

Assine nossa newsletter e receba as últimas atualizações e artigos diretamente em seu email.

Assinar gratuitamente