About this course
<p>Last year a client called me about exactly this. Someone ran <code>git log -p</code> on a hunch and found a <code>.env</code> committed two years earlier, never caught. Database password, Stripe secret, JWT signing key — all still active. All still in production.</p>
<p>IBM's 2024 breach cost report put the average data breach at <strong>$4.88 million</strong> — and that's the average, not the worst cases.</p>
<p>Exposed credentials are consistently near the top of root causes. GitHub found over a million secrets leaked in public repos in 2023 alone, before you even count the private ones nobody ever discovered.</p>
<p>It's not a people problem. The developers I've worked with aren't careless — the architecture is just set up to fail them. A <code>.env</code> file gets committed once by accident. Credentials get copied and pasted into a Slack message to unblock a teammate. A Docker image gets published with secrets baked into a layer. A server gets shut down, and nobody rotates the credentials it was holding.</p>
<p>Azure Key Vault solves this differently. Your application fetches credentials at runtime from a centralized, encrypted service — the <code>.env</code> file stops being a liability because it stops holding anything worth stealing.</p>
<p>What you'll build is a Node.js Express API that fetches every secret from Azure Key Vault at startup. No passwords in the code. When someone quits, there's nothing in the repo to rotate. The <code>.env</code> ends up with one line — the vault name.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>An Azure account (free tier works)</p>
</li>
<li><p>Azure CLI installed and logged in (<code>az login</code>)</p>
</li>
<li><p>Basic knowledge of Express.js</p>
</li>
<li><p>Docker (optional — only needed for the local database test section)</p>
</li>
</ul>
<h2 id="heading-what-we-will-build">What We Will Build</h2>
<p>A Node.js Express API that:</p>
<ol>
<li><p>Connects to PostgreSQL using credentials fetched from Key Vault at startup</p>
</li>
<li><p>Uses Managed Identity for authentication — no client secrets or passwords anywhere</p>
</li>
<li><p>Caches secrets in memory, so Key Vault isn't called on every request</p>
</li>
<li><p>Works locally via Azure CLI auth and in production via Managed Identity — same code, zero changes</p>
</li>
</ol>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-the-architecture-works">How the Architecture Works</a></p>
</li>
<li><p><a href="#heading-what-is-azure-key-vault">What Is Azure Key Vault?</a></p>
</li>
<li><p><a href="#heading-set-up-the-key-vault">Set Up the Key Vault</a></p>
</li>
<li><p><a href="#heading-create-the-nodejs-project">Create the Node.js Project</a></p>
</li>
<li><p><a href="#heading-connect-to-key-vault-with-managed-identity">Connect to Key Vault with Managed Identity</a></p>
</li>
<li><p><a href="#heading-cache-secrets-at-startup">Cache Secrets at Startup</a></p>
</li>
<li><p><a href="#heading-use-secrets-in-your-express-api">Use Secrets in Your Express API</a></p>
</li>
<li><p><a href="#heading-test-locally">Test Locally</a></p>
</li>
<li><p><a href="#heading-deploy-to-azure-app-service">Deploy to Azure App Service</a></p>
</li>
<li><p><a href="#heading-grant-key-vault-access-to-the-app">Grant Key Vault Access to the App</a></p>
</li>
<li><p><a href="#heading-rotate-secrets-without-redeploying">Rotate Secrets Without Redeploying</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-how-the-architecture-works">How the Architecture Works</h2>
<p>Before writing any code, it helps to see the full picture:</p>
<pre><code class="language-plaintext"> LOCAL DEVELOPMENT
.-------------------------------------------------------.
| |
| [Node.js App] |
| | |
| v |
| [DefaultAzureCredential] ---> az login session |
| | |
| v |
| [Azure Key Vault] ---> Returns secrets |
| | |
| v |
| [In-memory cache] ---> App uses secrets at runtime |
'-------------------------------------------------------'
PRODUCTION (Azure)
.-------------------------------------------------------.
| |
| [Azure App Service] |
| | |
| v |
| [DefaultAzureCredential] ---> Managed Identity |
| | |
| v |
| [Azure Key Vault] ---> Returns secrets |
| | |
| v |
| [In-memory cache] ---> App uses secrets at runtime |
'-------------------------------------------------------'
</code></pre>
<p>Both environments run the exact same code. <code>DefaultAzureCredential</code> figures out where it is — locally it picks up your <code>az login</code> session, on Azure it uses Managed Identity. You don't switch config files and you don't manage credentials. It just works.</p>
<h2 id="heading-what-is-azure-key-vault">What Is Azure Key Vault?</h2>
<p>Azure Key Vault is Microsoft's managed secret store — it handles secrets, keys, and certificates. For this tutorial, we're only using the secrets part: database passwords, API keys, JWT signing keys, anything your app needs to run but has no business being in your Git history.</p>
<p>Compared to <code>.env</code> files, the practical differences are worth understanding before you write any code.</p>
<p>Rotation is the one I notice most on real projects. Update a secret in Key Vault and every app picks it up on the next restart — no hunting down five different environment configs across staging and production.</p>
<p>Access control is the other big one. Each application only gets permission to read the secrets it actually needs. If one service gets compromised, it can't read credentials belonging to other services.</p>
<p>And every read gets logged. When something goes wrong — and eventually something will — you can see exactly which app accessed which secret, and when. That log is what auditors actually want to see.</p>
<p>I've sat in enough security reviews to know that "we use <code>.env</code> files and tell people not to commit them" doesn't satisfy an auditor. SOC 2, HIPAA, GDPR — they all want demonstrable controls. A vault with an access log is demonstrable.</p>
<h2 id="heading-set-up-the-key-vault">Set Up the Key Vault</h2>
<p>Run these commands. The vault name has to be globally unique across all of Azure — not just your own subscription — so pick something specific. Letters, numbers, and hyphens, 3 to 24 characters.</p>
<pre><code class="language-bash"># Create a resource group (skip if you already have one)
az group create \
--name keyvault-demo-rg \
--location eastus
# Create the Key Vault (RBAC enabled by default — required for the role assignment later)
az keyvault create \
--name your-vault-name \
--resource-group keyvault-demo-rg \
--location eastus
# Grant yourself permission to manage secrets (required with RBAC — creators are not auto-assigned)
az role assignment create \
--role "Key Vault Secrets Officer" \
--assignee-object-id $(az ad signed-in-user show --query id -o tsv) \
--scope $(az keyvault show \
--name your-vault-name \
--resource-group keyvault-demo-rg \
--query id -o tsv)
# Add your secrets
az keyvault secret set \
--vault-name your-vault-name \
--name "DB-HOST" \
--value "your-db-host.postgres.database.azure.com"
az keyvault secret set \
--vault-name your-vault-name \
--name "DB-PASSWORD" \
--value "your-super-secret-password"
az keyvault secret set \
--vault-name your-vault-name \
--name "JWT-SECRET" \
--value "your-jwt-signing-secret"
</code></pre>
<p>Verify the secrets were stored:</p>
<pre><code class="language-bash">az keyvault secret list --vault-name your-vault-name --query "[].name" -o tsv
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">DB-HOST
DB-PASSWORD
JWT-SECRET
</code></pre>
<h2 id="heading-create-the-nodejs-project">Create the Node.js Project</h2>
<p>Set up the project structure:</p>
<pre><code class="language-bash">mkdir nodejs-azure-keyvault
cd nodejs-azure-keyvault
npm init -y
npm install express pg jsonwebtoken @azure/keyvault-secrets @azure/identity dotenv
</code></pre>
<p>The two Azure packages do all the work:</p>
<ul>
<li><p><code>@azure/keyvault-secrets</code> — connects to your vault and pulls secrets out</p>
</li>
<li><p><code>@azure/identity</code> — handles auth. Locally, it uses your <code>az login</code> session, in production, it switches to Managed Identity automatically</p>
</li>
</ul>
<p>Add a start script to <code>package.json</code>:</p>
<pre><code class="language-bash">npm pkg set scripts.start="node server.js"
</code></pre>
<p>Create the following file structure:</p>
<pre><code class="language-plaintext">nodejs-azure-keyvault/
|-- src/
| |-- config/
| | `-- secrets.js # Key Vault client and secret loader
| |-- db/
| | `-- index.js # PostgreSQL pool using secrets
| `-- routes/
| `-- users.js # Example route
|-- app.js # Express app
`-- server.js # Entry point -- loads secrets first
</code></pre>
<h2 id="heading-connect-to-key-vault-with-managed-identity">Connect to Key Vault with Managed Identity</h2>
<p>Create the secrets config file:</p>
<pre><code class="language-javascript">// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');
const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;
const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);
async function getSecret(name) {
const secret = await client.getSecret(name);
return secret.value;
}
module.exports = { getSecret };
</code></pre>
<p><code>DefaultAzureCredential</code> is the most important part of this setup. It tries a chain of authentication methods in order:</p>
<ol>
<li><p>Environment variables (for CI/CD pipelines)</p>
</li>
<li><p>Azure CLI credentials (for local development — <code>az login</code>)</p>
</li>
<li><p>Managed Identity (for deployed apps on Azure)</p>
</li>
</ol>
<p>This means the exact same code works locally and in production with zero changes. Locally, it uses your <code>az login</code> session. In production, it uses the app's Managed Identity. You never touch credentials.</p>
<h2 id="heading-cache-secrets-at-startup">Cache Secrets at Startup</h2>
<p>Calling Key Vault on every request adds latency and costs money. Load all secrets once at startup and cache them in memory. Replace <code>src/config/secrets.js</code> with this complete version:</p>
<pre><code class="language-javascript">// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');
const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;
const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);
// In-memory cache
const cache = {};
async function getSecret(name) {
if (cache[name]) return cache[name];
const secret = await client.getSecret(name);
cache[name] = secret.value;
return secret.value;
}
async function loadAllSecrets() {
console.log('Loading secrets from Azure Key Vault...');
const secretNames = ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'];
await Promise.all(
secretNames.map(async (name) => {
cache[name] = await getSecret(name);
console.log(` ✓ ${name} loaded`);
})
);
console.log('All secrets loaded successfully.');
}
function getFromCache(name) {
if (!cache[name]) throw new Error(`Secret "${name}" not loaded. Did loadAllSecrets() run?`);
return cache[name];
}
module.exports = { loadAllSecrets, getFromCache };
</code></pre>
<p>The <code>loadAllSecrets</code> function runs once when the application starts. After that, all secrets are served from the in-memory cache with zero latency and zero Key Vault calls.</p>
<h2 id="heading-use-secrets-in-your-express-api">Use Secrets in Your Express API</h2>
<p>Set up the database connection using the cached secrets:</p>
<pre><code class="language-javascript">// src/db/index.js
const { Pool } = require('pg');
const { getFromCache } = require('../config/secrets');
let pool;
function getPool() {
if (!pool) {
pool = new Pool({
host: getFromCache('DB-HOST'),
database: process.env.DB_NAME || 'myapp',
user: process.env.DB_USER || 'dbadmin',
password: getFromCache('DB-PASSWORD'),
port: parseInt(process.env.DB_PORT || '5432'),
ssl: process.env.NODE_ENV === 'production'
? { rejectUnauthorized: false }
: false,
});
pool.on('error', (err) => {
console.error('Unexpected database pool error:', err.message);
});
}
return pool;
}
module.exports = { getPool };
</code></pre>
<p>Notice the distinction: <code>DB-HOST</code> and <code>DB-PASSWORD</code> come from Key Vault because they're sensitive. The database name, username, and port are not — they don't need to be protected, so they use environment variables with sensible defaults. Key Vault is for credentials, not all configuration.</p>
<p>The SSL flag is environment-aware: forced on in production, off locally so Docker connections work without a certificate. The <code>rejectUnauthorized: false</code> setting accepts Azure Database for PostgreSQL's certificate without verifying the CA chain — this is standard for Azure-managed databases. For stricter environments, you can download the Azure root CA and pass it via the <code>ca</code> option in the pool config instead.</p>
<p>Create a sample route that uses JWT verification with the secret from Key Vault:</p>
<pre><code class="language-javascript">// src/routes/users.js
const express = require('express');
const jwt = require('jsonwebtoken');
const { getFromCache } = require('../config/secrets');
const { getPool } = require('../db');
const router = express.Router();
// Auth middleware — JWT secret comes from Key Vault, not process.env
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed Authorization header' });
}
const token = authHeader.split(' ')[1];
try {
req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// GET /api/users — list users (authenticated)
router.get('/', authMiddleware, async (req, res) => {
try {
const result = await getPool().query(
'SELECT id, email, created_at FROM users ORDER BY created_at DESC LIMIT 20'
);
res.json(result.rows);
} catch (err) {
console.error('Database error:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
// GET /api/users/:id — single user (authenticated)
router.get('/:id', authMiddleware, async (req, res) => {
try {
const result = await getPool().query(
'SELECT id, email, created_at FROM users WHERE id = $1',
[req.params.id]
);
if (!result.rows[0]) return res.status(404).json({ error: 'User not found' });
res.json(result.rows[0]);
} catch (err) {
console.error('Database error:', err.message);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router;
</code></pre>
<p>Notice the error handler returns <code>'Internal server error'</code> instead of <code>err.message</code>. Database errors are surprisingly chatty — they'll hand an attacker your table names, column names, and query structure if you let them through.</p>
<p>Set up the Express application. Both files define <code>authMiddleware</code> locally — yes, it's duplicated. In production, I'd pull this into a shared middleware file. For this tutorial, keeping it local means you can read either file without bouncing between three others:</p>
<pre><code class="language-javascript">// app.js
const express = require('express');
const jwt = require('jsonwebtoken');
const { getFromCache } = require('./src/config/secrets');
const usersRouter = require('./src/routes/users');
const app = express();
app.use(express.json());
// Auth middleware — JWT secret comes from Key Vault, not process.env
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed Authorization header' });
}
const token = authHeader.split(' ')[1];
try {
req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
// Health check — no auth required
app.get('/health', (req, res) => {
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
// Status endpoint — proves Key Vault integration without needing a database
app.get('/api/status', authMiddleware, (req, res) => {
res.json({
message: 'All secrets loaded from Azure Key Vault',
vault: process.env.KEY_VAULT_NAME,
secrets_loaded: ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'],
authenticated_as: req.user.email,
timestamp: new Date().toISOString()
});
});
app.use('/api/users', usersRouter);
app.use((req, res) => res.status(404).json({ error: 'Route not found' }));
app.use((err, req, res, next) => {
console.error('Unhandled error:', err.message);
res.status(500).json({ error: 'Internal server error' });
});
module.exports = app;
</code></pre>
<p>The entry point loads secrets before starting the server. The server doesn't start unless all secrets load successfully:</p>
<pre><code class="language-javascript">// server.js
require('dotenv').config();
const app = require('./app');
const { loadAllSecrets } = require('./src/config/secrets');
const PORT = process.env.PORT || 3000;
async function start() {
try {
await loadAllSecrets();
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
} catch (err) {
console.error('Failed to start server:', err.message);
console.error('Hint: Run "az login" for local development, or check Managed Identity for Azure deployments.');
process.exit(1);
}
}
start();
</code></pre>
<p>That <code>process.exit(1)</code> is deliberate. I'd rather the app crash loudly at startup than limp along with missing credentials and fail on the first real request two hours later.</p>
<h2 id="heading-test-locally">Test Locally</h2>
<p>Create a <code>.env</code> file for local development. This only contains the Key Vault name, nothing sensitive:</p>
<pre><code class="language-bash"># .env
KEY_VAULT_NAME=your-vault-name
PORT=3000
</code></pre>
<p>Add <code>.env</code> and the deployment zip to <code>.gitignore</code>:</p>
<pre><code class="language-bash">echo ".env" >> .gitignore
echo "app.zip" >> .gitignore
</code></pre>
<p>Make sure you're logged into Azure CLI:</p>
<pre><code class="language-bash">az login
</code></pre>
<p>Start the application:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">Loading secrets from Azure Key Vault...
✓ JWT-SECRET loaded
✓ DB-PASSWORD loaded
✓ DB-HOST loaded
All secrets loaded successfully.
Server running on port 3000
</code></pre>
<p>The order secrets load may vary — <code>Promise.all</code> fetches them in parallel and resolves as each one completes. What matters is that all three are confirmed before the server starts.</p>
<p>Test the health endpoint:</p>
<pre><code class="language-bash">curl http://localhost:3000/health
# {"status":"healthy","timestamp":"2026-07-14T19:38:11.659Z"}
</code></pre>
<p>Now prove the integration end-to-end. Grab the value you stored as <code>JWT-SECRET</code> and use it to sign a test token — paste it in for <code>YOUR-JWT-SECRET-VALUE</code>. Then hit <code>/api/status</code> with it:</p>
<pre><code class="language-bash">node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-bash">curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/status
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-RestMethod -Uri "http://localhost:3000/api/status" -Headers @{Authorization = "Bearer YOUR_TOKEN"}
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">{
"message": "All secrets loaded from Azure Key Vault",
"vault": "your-vault-name",
"secrets_loaded": ["DB-HOST", "DB-PASSWORD", "JWT-SECRET"],
"authenticated_as": "test@test.com",
"timestamp": "2026-07-14T19:50:08.687Z"
}
</code></pre>
<p>If you got that response, the whole chain worked. The JWT was signed and verified using a secret that lived only in Key Vault — not in your code, not in your<code>.env</code>, not anywhere in the repo. Your <code>az login</code> session handled the auth locally. In production, Managed Identity takes over. Same code, nothing changes.</p>
<h3 id="heading-test-the-full-database-flow-with-docker">Test the Full Database Flow with Docker</h3>
<p>The app reads <code>DB-HOST</code> and <code>DB-PASSWORD</code> from Key Vault, so those secrets need to match your local Docker container. Update them now:</p>
<pre><code class="language-bash">az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "localhost"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "demopassword123"
</code></pre>
<p>Docker up a Postgres container. The password has to match <code>demopassword123</code> — that's what you just put in Key Vault:</p>
<pre><code class="language-bash">docker run --name pg-demo \
-e POSTGRES_USER=dbadmin \
-e POSTGRES_PASSWORD=demopassword123 \
-e POSTGRES_DB=myapp \
-p 5432:5432 \
-d postgres:15
</code></pre>
<p>Get the table created and throw in some test rows:</p>
<pre><code class="language-bash">docker exec -it pg-demo psql -U dbadmin -d myapp -c \
"CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW());"
docker exec -it pg-demo psql -U dbadmin -d myapp -c \
"INSERT INTO users (email) VALUES ('alice@example.com'), ('bob@example.com'), ('carol@example.com');"
</code></pre>
<p>Kill the server and bring it back up — secrets load at startup, so it needs a fresh run to pick up what you just changed in Key Vault:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>Call the users endpoint with a valid JWT:</p>
<pre><code class="language-bash"># Generate a token (use the same value you stored as JWT-SECRET in Key Vault)
node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-bash">curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/users
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-RestMethod -Uri "http://localhost:3000/api/users" -Headers @{Authorization = "Bearer YOUR_TOKEN"}
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">[
{ "id": 1, "email": "alice@example.com", "created_at": "2026-07-14T19:59:21.064Z" },
{ "id": 2, "email": "bob@example.com", "created_at": "2026-07-14T19:59:21.064Z" },
{ "id": 3, "email": "carol@example.com", "created_at": "2026-07-14T19:59:21.064Z" }
]
</code></pre>
<p>That query ran using a password that came straight from Key Vault. It's not in your <code>.env</code>, not hardcoded anywhere, and not in a local variable. The repo has nothing worth stealing.</p>
<p>Before you deploy, put the real production values back in Key Vault:</p>
<pre><code class="language-bash">az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "your-db-host.postgres.database.azure.com"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "your-super-secret-password"
</code></pre>
<p>If you skip this, the deployed app will try to connect to <code>localhost</code> and fail immediately — <code>localhost</code> doesn't exist on App Service.</p>
<h2 id="heading-deploy-to-azure-app-service">Deploy to Azure App Service</h2>
<p><strong>Note:</strong> This section creates the App Service infrastructure. The actual code deployment (zip upload) happens at the end of the next section — the app must have Key Vault access configured before its first startup, or it will fail immediately and exit.</p>
<p>Create the App Service:</p>
<pre><code class="language-bash"># Create an App Service Plan (B1 is the cheapest paid tier)
az appservice plan create \
--name keyvault-demo-plan \
--resource-group keyvault-demo-rg \
--sku B1 \
--is-linux
# Create the Web App
az webapp create \
--name my-keyvault-node-app \
--resource-group keyvault-demo-rg \
--plan keyvault-demo-plan \
--runtime "NODE:18-lts"
# Set app settings — KEY_VAULT_NAME tells the app which vault to use
# NODE_ENV=production enables SSL for the database connection
az webapp config appsettings set \
--name my-keyvault-node-app \
--resource-group keyvault-demo-rg \
--settings KEY_VAULT_NAME=your-vault-name NODE_ENV=production
</code></pre>
<h2 id="heading-grant-key-vault-access-to-the-app">Grant Key Vault Access to the App</h2>
<p>Enable Managed Identity on the app. This gives it an identity in Microsoft Entra ID that Key Vault can trust:</p>
<pre><code class="language-bash"># Enable system-assigned managed identity
az webapp identity assign \
--name my-keyvault-node-app \
--resource-group keyvault-demo-rg
</code></pre>
<p>The following commands capture the <code>principalId</code> automatically and use it to grant the role:</p>
<pre><code class="language-bash"># Get the principal ID
PRINCIPAL_ID=$(az webapp identity show \
--name my-keyvault-node-app \
--resource-group keyvault-demo-rg \
--query principalId \
--output tsv)
# Get the Key Vault resource ID
KV_ID=$(az keyvault show \
--name your-vault-name \
--resource-group keyvault-demo-rg \
--query id \
--output tsv)
# Grant the app the "Key Vault Secrets User" role
az role assignment create \
--role "Key Vault Secrets User" \
--assignee-object-id $PRINCIPAL_ID \
--scope $KV_ID
</code></pre>
<p>The <code>Key Vault Secrets User</code> role allows the app to read secrets. It can't create, update, or delete them. This is the principle of least privilege — the application can only do what it needs to do.</p>
<p>Time to ship it. Linux/macOS can run this directly — Windows users, open Git Bash (it ships with Git for Windows):</p>
<pre><code class="language-bash">zip -r app.zip . -x "node_modules/*" ".git/*" ".env" "app.zip"
</code></pre>
<p>Then deploy:</p>
<pre><code class="language-bash">az webapp deployment source config-zip \
--name my-keyvault-node-app \
--resource-group keyvault-demo-rg \
--src app.zip
</code></pre>
<p>The deployed application authenticates to Key Vault using its Managed Identity automatically. No passwords, no client secrets, no credentials of any kind in the deployment.</p>
<p>Check the health endpoint to confirm it's running:</p>
<pre><code class="language-bash">curl https://my-keyvault-node-app.azurewebsites.net/health
# {"status":"healthy","timestamp":"..."}
</code></pre>
<p>If it won't start, pull the logs:</p>
<pre><code class="language-bash">az webapp log tail --name my-keyvault-node-app --resource-group keyvault-demo-rg
</code></pre>
<p>Nine times out of ten, it's that the Key Vault role assignment has not been propagated yet. Give it 2–3 minutes, then restart:</p>
<pre><code class="language-bash">az webapp restart --name my-keyvault-node-app --resource-group keyvault-demo-rg
</code></pre>
<h2 id="heading-rotate-secrets-without-redeploying">Rotate Secrets Without Redeploying</h2>
<p>One of the biggest practical benefits of Key Vault is secret rotation. When a database password needs to change, you update it in Key Vault — not in your app:</p>
<pre><code class="language-bash">az keyvault secret set \
--vault-name your-vault-name \
--name "DB-PASSWORD" \
--value "new-rotated-password"
</code></pre>
<p>The cache builds at startup, so you don't need a redeploy — a restart is enough:</p>
<pre><code class="language-bash">az webapp restart \
--name my-keyvault-node-app \
--resource-group keyvault-demo-rg
</code></pre>
<p>No code change. No new deployment. The secret is rotated, and the app is using the new value in seconds.</p>
<p>If you need zero-downtime rotation, add a <code>/refresh-secrets</code> endpoint behind admin auth that clears the cache and then calls <code>loadAllSecrets()</code>. The order matters — <code>loadAllSecrets()</code> uses <code>getSecret()</code> which returns cached values if they exist, so you must clear the cache first, or it will reload nothing. This is optional but useful for long-running processes that can't afford a restart.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p><code>CredentialUnavailableError: DefaultAzureCredential failed to retrieve a token</code></p>
<p>You're not logged into Azure CLI. Run <code>az login</code> and try again. On Azure App Service, check that Managed Identity is enabled and the role assignment was created correctly.</p>
<p><code>RestError: Forbidden — The user does not have secrets get permission</code></p>
<p>The Managed Identity isn't wired up to Key Vault yet. Go back and run the <code>az role assignment create</code> command. If you already did, it might just need time. Azure can take 2–3 minutes to propagate role assignments, so give it a moment before you dig further.</p>
<p><code>Error: Secret "DB-PASSWORD" not loaded. Did loadAllSecrets() run?</code></p>
<p><code>getFromCache()</code> ran before <code>loadAllSecrets()</code> finished, meaning the startup sequence is out of order. Open <code>server.js</code> and confirm <code>await loadAllSecrets()</code> comes before <code>app.listen()</code>. If the order's fine, the secret might just not be in the vault yet. Run <code>az keyvault secret list --vault-name YOUR_VAULT</code> to double-check. (A name mismatch — wrong case, typo — throws <code>SecretNotFound</code> instead, which is the entry below.)</p>
<p><strong>App starts locally but fails on Azure App Service</strong></p>
<p>Almost always, the app setting. Either <code>KEY_VAULT_NAME</code> isn't in App Service configuration at all, or the vault name has a typo. Run <code>az webapp log tail</code> to see the actual startup error — that'll tell you which one.</p>
<p><code>AuthorizationFailed</code> <strong>when running</strong> <code>az role assignment create</code></p>
<p>You are a guest user in your Azure tenant and lack the Owner role needed to assign roles. Switch the existing vault to the access policy model — no need to recreate it or lose your secrets:</p>
<pre><code class="language-bash">az keyvault update \
--name your-vault-name \
--resource-group keyvault-demo-rg \
--enable-rbac-authorization false
</code></pre>
<p>If this happened during <strong>Set Up the Key Vault</strong> (granting yourself access), run:</p>
<pre><code class="language-bash">az keyvault set-policy \
--name your-vault-name \
--object-id $(az ad signed-in-user show --query id -o tsv) \
--secret-permissions get set list delete
</code></pre>
<p>If this happened during <strong>Grant Key Vault Access to the App</strong> (granting the Managed Identity access), run:</p>
<pre><code class="language-bash">az keyvault set-policy \
--name your-vault-name \
--object-id $PRINCIPAL_ID \
--secret-permissions get list
</code></pre>
<p><strong>Key Vault returns</strong> <code>SecretNotFound</code></p>
<p>The secret was never added, was deleted, or its name doesn't match exactly what your code requests — Key Vault secret names are case-sensitive. A secret named <code>db-password</code> and a request for <code>DB-PASSWORD</code> are different names. Run <code>az keyvault secret list --vault-name YOUR_VAULT</code> and compare what's actually in the vault against what <code>loadAllSecrets()</code> is asking for in <code>src/config/secrets.js</code>. Usually, it's a casing issue or a stray hyphen.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The <code>.env</code> file in this project contains exactly one value: the Key Vault name. That's not sensitive. Every actual secret — database passwords, API keys, signing secrets — lives in Key Vault and never touches your codebase or your deployment pipeline.</p>
<p>This is the pattern I use on Azure projects now. The startup check is the part I find most useful in practice: if Key Vault is unreachable or a secret is missing, the server exits immediately with a clear error instead of starting up broken and failing on the first real request. You find out right away, rather than getting an obscure database connection error two hours later.</p>
<p>To add another secret, put it in Key Vault and drop its name into the <code>secretNames</code> array — that's it. Everything else scales with it.</p>
<p>The full working code is on GitHub: <a href="https://github.com/ziaongit/nodejs-azure-keyvault">nodejs-azure-keyvault</a></p>