Skip to main content

Reference Key Vault secrets from App Service

Apps need secrets - a database password, an API token, a signing key. The tempting shortcut is to paste the secret straight into an app setting, but then the raw value lives in your configuration, shows up in exports, and has to be rotated by hand everywhere it was copied. This lab shows the recommended pattern instead: store the secret in Azure Key Vault, and point an app setting at it with a Key Vault reference. Azure App Service resolves the reference at runtime using the app's managed identity, so your app reads the secret as an ordinary environment variable - with no key, secret, or vault SDK in your code.

In this lab you will:

  • Deploy a small web app that echoes a secret it reads from an environment variable (Python and Node.js are fully worked, with snippets for .NET, Java, and PHP).
  • Create a Key Vault in RBAC authorization mode and add a secret.
  • Turn on the app's system-assigned managed identity and grant it the Key Vault Secrets User role.
  • Add an app setting whose value is a Key Vault reference: @Microsoft.KeyVault(SecretUri=...).
  • Deploy three ways - Azure Developer CLI (azd), Azure CLI (az), and the Azure portal - and confirm the app serves the resolved secret with no secret in code or configuration.

This is the keyless, no-secrets-in-config pattern. The secret value lives only in Key Vault; your configuration holds a reference, and only the app's identity can read it.

App Service Labs complements Microsoft Learn

This lab is a hands-on, end-to-end walkthrough. For reference depth on any concept, follow the "Learn more" links to the official Microsoft Learn documentation.

Estimated time: 30 to 40 minutes.

Objectives

By the end of this lab you will be able to:

  • Explain why a Key Vault reference is safer than storing a secret in an app setting.
  • Create a Key Vault in RBAC authorization mode and store a secret.
  • Turn on a managed identity and grant it read access to secrets with the Key Vault Secrets User role.
  • Wire an app setting to a Key Vault reference and confirm App Service resolves it.

Prerequisites

Before you begin, you will need an Azure subscription with Owner permissions and a GitHub account.

In addition, you will need the following tools installed on your local machine:

Setup Azure CLI

Start by logging into Azure by run the following command and follow the prompts:

az login --use-device-code
tip

You can log into a different tenant by passing in the --tenant flag to specify your tenant domain or tenant ID.

You also need permission to create role assignments on the Key Vault (Owner, or User Access Administrator on the vault). Granting the managed identity its role, and granting yourself access to write the secret, both require this.

Choose a region and low-cost tier

This lab uses the East US region and the B1 (Basic) App Service plan - a low-cost Linux plan (about USD 13/month) that is ideal for learning. Key Vault standard secrets are billed per 10,000 operations and cost almost nothing for this lab. Delete the resources when you are done (see Clean up) to stop charges.

How a Key Vault reference works

App Service reads a Key Vault reference for you. When an app setting's value looks like @Microsoft.KeyVault(SecretUri=...), App Service uses the app's managed identity to fetch the secret from Key Vault and injects the resolved value into the app as a normal environment variable. Your code never sees the vault, the reference syntax, or a key - it just reads os.environ["SECRET_MESSAGE"] (or the equivalent) like any other setting.

Two things make this keyless and safe:

  • The Key Vault uses RBAC authorization (not access policies), so access is granted with standard Azure roles. The app's identity gets exactly one role: Key Vault Secrets User, which allows reading secret values and nothing else.
  • The secret value is never stored in your app configuration. The app setting holds only the reference. Anyone who reads your configuration sees @Microsoft.KeyVault(SecretUri=...), not the secret.

The app sample

Choose your path
Set these once - the sample code and every deployment step below follow your choice.
Language
Deploy with

The sample exposes a /health endpoint for App Service health checks and a /secret endpoint that returns the value it read from the SECRET_MESSAGE environment variable. Because a Key Vault reference resolves to a plain environment variable, the app is identical to one that reads any other app setting - there is no Key Vault code. Pick your language.

Create a project with these two files.

requirements.txt

flask
gunicorn

app.py

import os

from flask import Flask, jsonify

app = Flask(__name__)


@app.route("/health")
def health():
return jsonify(status="ok")


@app.route("/")
def home():
return (
"<h1>Key Vault reference demo on Azure App Service</h1>"
"<p>GET <code>/secret</code> to see the value resolved from Key Vault.</p>"
)


@app.route("/secret")
def secret():
# SECRET_MESSAGE is populated by a Key Vault reference app setting.
# The app reads it as a normal environment variable - no key or secret in code.
value = os.environ.get("SECRET_MESSAGE", "<not set>")
return jsonify(secretMessage=value, source="Key Vault reference via managed identity")


if __name__ == "__main__":
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 8000)))

Create the resources and wire up the reference

Choose one path. All three create a B1 Linux App Service, a Key Vault in RBAC authorization mode with a secret named demo-secret, turn on the app's system-assigned managed identity, grant it the Key Vault Secrets User role, and set an app setting to the Key Vault reference. The demo secret value is a throwaway string; never use a real secret in a lab.

The Azure Developer CLI provisions everything - the App Service, the Key Vault, the secret, the managed identity, and the role assignment - then deploys your code in one step. Add these files to your project.

azure.yaml

# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
name: keyvault-references-appservice
services:
web:
project: .
language: py # use "js" for the Node.js variant
host: appservice

infra/main.bicep

targetScope = 'subscription'

param resourceGroupName string
param location string
param appServicePlanName string
param webAppName string
param keyVaultName string
param secretName string = 'demo-secret'
@secure()
param secretValue string

resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = {
name: resourceGroupName
location: location
}

module resources 'resources.bicep' = {
name: 'kvref-resources'
scope: rg
params: {
location: location
appServicePlanName: appServicePlanName
webAppName: webAppName
keyVaultName: keyVaultName
secretName: secretName
secretValue: secretValue
}
}

output WEB_URI string = resources.outputs.webUri
output KEY_VAULT_NAME string = keyVaultName

infra/resources.bicep

param location string
param appServicePlanName string
param webAppName string
param keyVaultName string
param secretName string = 'demo-secret'
@secure()
param secretValue string

// Role definition ID for "Key Vault Secrets User" (read secret values only).
var keyVaultSecretsUserRoleId = '4633458b-17de-408a-b874-0445c86b69e6'

resource plan 'Microsoft.Web/serverfarms@2023-12-01' = {
name: appServicePlanName
location: location
sku: { name: 'B1', tier: 'Basic' }
kind: 'linux'
properties: { reserved: true }
}

// Key Vault in RBAC authorization mode (no access policies).
resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = {
name: keyVaultName
location: location
properties: {
tenantId: subscription().tenantId
sku: { family: 'A', name: 'standard' }
enableRbacAuthorization: true
}
}

// Creating a secret through Bicep is a management-plane (ARM) operation, so the
// deploying identity needs Contributor on the vault - not a data-plane role.
resource secret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = {
parent: vault
name: secretName
properties: {
value: secretValue
}
}

resource web 'Microsoft.Web/sites@2023-12-01' = {
name: webAppName
location: location
kind: 'app,linux'
tags: { 'azd-service-name': 'web' }
identity: { type: 'SystemAssigned' }
properties: {
serverFarmId: plan.id
httpsOnly: true
siteConfig: {
linuxFxVersion: 'PYTHON|3.12' // use 'NODE|22-lts' for Node.js
alwaysOn: true
minTlsVersion: '1.2'
healthCheckPath: '/health'
appCommandLine: 'gunicorn --bind=0.0.0.0 --timeout 600 app:app' // Node.js: leave empty (uses npm start)
appSettings: [
{ name: 'SCM_DO_BUILD_DURING_DEPLOYMENT', value: 'true' }
// Key Vault reference: App Service resolves this with the app's managed
// identity at runtime. No secret value is stored in configuration.
{ name: 'SECRET_MESSAGE', value: '@Microsoft.KeyVault(SecretUri=${vault.properties.vaultUri}secrets/${secretName}/)' }
]
}
}
}

// Grant the web app's managed identity keyless read access to secrets.
resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(vault.id, web.id, keyVaultSecretsUserRoleId)
scope: vault
properties: {
principalId: web.identity.principalId
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', keyVaultSecretsUserRoleId)
principalType: 'ServicePrincipal'
}
}

output webUri string = 'https://${web.properties.defaultHostName}'

infra/main.parameters.json

{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"resourceGroupName": { "value": "${AZURE_RESOURCE_GROUP}" },
"location": { "value": "${AZURE_LOCATION}" },
"appServicePlanName": { "value": "${APP_SERVICE_PLAN_NAME}" },
"webAppName": { "value": "${WEB_APP_NAME}" },
"keyVaultName": { "value": "${KEY_VAULT_NAME}" },
"secretName": { "value": "${SECRET_NAME=demo-secret}" },
"secretValue": { "value": "${SECRET_VALUE}" }
}
}

Create an environment and set names. Use a unique suffix so the App Service hostname and vault name are globally unique:

SUFFIX=$(openssl rand -hex 3)
azd env new kvref --location eastus --subscription <your-subscription-id>
azd env set AZURE_RESOURCE_GROUP "rg-asl-kvref-${SUFFIX}"
azd env set APP_SERVICE_PLAN_NAME "plan-asl-kvref-${SUFFIX}"
azd env set WEB_APP_NAME "app-asl-kvref-${SUFFIX}"
azd env set KEY_VAULT_NAME "kv-asl-${SUFFIX}"
azd env set SECRET_NAME "demo-secret"
azd env set SECRET_VALUE "keyless-hello-from-key-vault"

Provision and deploy:

azd up

When it finishes, azd prints the app URL, for example:

Endpoint: https://app-asl-kvref-xxxxxx.azurewebsites.net/
SUCCESS: Your application was deployed to Azure in 5 minutes 40 seconds.

Because the role assignment and the reference are both in the Bicep, the reference resolves as soon as the app starts - no extra steps. Skip to Verify the app.

First deploy

On the very first azd up, azd occasionally reports it cannot find the tagged resource because provisioning outputs are not cached yet. If that happens, run azd deploy once more - the resources already exist and the code deploy completes.

Verify the app

Set APP_URL to your app's hostname, then test the health and secret endpoints:

export APP_URL="https://app-asl-kvref-xxxxxx.azurewebsites.net"

# 1) Health check
curl -i "$APP_URL/health"

# 2) The app returns the value it read from the SECRET_MESSAGE environment variable,
# which App Service resolved from Key Vault using the managed identity.
curl -s "$APP_URL/secret"

Expected results (captured during authoring):

HTTP/1.1 200 OK
{"status":"ok"}
{"secretMessage":"keyless-hello-from-key-vault","source":"Key Vault reference via managed identity"}

Seeing the secret value in the response proves the reference resolved: App Service used the app's managed identity to read demo-secret from Key Vault and injected it as an environment variable.

Confirm your configuration holds the reference, not the secret value, and that App Service reports it as Resolved:

# The stored value is the reference - the secret itself is never in configuration.
az webapp config appsettings list --resource-group "$RG_NAME" --name "$APP" \
--query "[?name=='SECRET_MESSAGE'].value" --output tsv

# Reference status should be "Resolved".
az rest --method get \
--uri "https://management.azure.com/subscriptions/<your-subscription-id>/resourceGroups/${RG_NAME}/providers/Microsoft.Web/sites/${APP}/config/configreferences/appsettings?api-version=2023-12-01" \
--query "value[?name=='SECRET_MESSAGE'].{name:name, status:properties.status}" --output json

Expected output:

@Microsoft.KeyVault(SecretUri=https://kv-asl-xxxxxx.vault.azure.net/secrets/demo-secret/)
[
{
"name": "SECRET_MESSAGE",
"status": "Resolved"
}
]

In the portal, the same status appears on Settings > Environment variables: the SECRET_MESSAGE app setting shows a Key vault Reference source with a green checkmark.

First request can be slow

The first request after a deployment may take a few seconds while the app starts. If you see a 503 or an unresolved reference immediately after granting the role, wait a minute for the role assignment to propagate, then restart the app and try again.

Cleanup

To clean up the resources created in this lab, run the following command to delete the resource group. If you want to use the resources again, you can skip this step.

az group delete \
--name ${RG_NAME} \
--yes \
--no-wait

This will delete the resource group and all its contents.

If you deployed with azd, tear everything down with a single command instead:

azd down --force --purge

Confirm the resource group is gone:

az group exists --name "$RG_NAME" # should print: false
Purge the soft-deleted vault

Key Vault has soft delete on by default, so a deleted vault is retained (and its name reserved) until it is purged or the retention period passes. If you plan to recreate a vault with the same name, purge it after the resource group is deleted:

az keyvault purge --name "$VAULT"

Summary

You deployed a small web app to Azure App Service and gave it a secret without ever putting the secret in your code or configuration. The secret lives in a Key Vault that uses RBAC authorization; the app's system-assigned managed identity holds only the Key Vault Secrets User role; and an app setting points at the secret with a @Microsoft.KeyVault(SecretUri=...) reference. App Service resolves that reference at runtime and hands your app a plain environment variable, so the same code works whether a setting is a Key Vault reference or a literal value. This is the keyless, no-secrets-in-config pattern - rotate the secret in Key Vault and every app that references it picks up the new value, with nothing to redeploy.

Troubleshooting

  • The reference shows an error, or /secret returns <not set>. App Service could not resolve the reference. Confirm the app has a system-assigned managed identity, that the identity holds Key Vault Secrets User on the vault, and that the vault uses RBAC authorization. Role assignments can take a minute to propagate; restart the app after granting the role.
  • Forbidden when running az keyvault secret set. In RBAC mode, writing a secret needs a data-plane role. Grant your user the Key Vault Secrets Officer role on the vault and wait a minute before retrying.
  • (BadRequest) ... SecretUri or the reference will not save. Check the reference syntax: @Microsoft.KeyVault(SecretUri=https://<vault>.vault.azure.net/secrets/<name>/). Use the vault's DNS name, the exact secret name, and end with a trailing slash (no version) to track the latest version.
  • The value updates in Key Vault but the app still serves the old one. App Service caches resolved references. Restart the app, or set a new version and confirm the reference is versionless so it picks up the latest.
  • 503 right after deploy. The app is still starting. Turn on Always On, confirm the startup command, and check logs with az webapp log tail --resource-group "$RG_NAME" --name "$APP".

Learn more