How to Secure Node.js Secrets With Azure Key Vault and Managed Identity
Learn how to move Node.js secrets out of .env files into Azure Key Vault, using the Azure CLI locally and a managed identity in production, with no code changes.
A .env file feels safe right up until it is not. It gets committed by accident, pasted into a Slack thread to unblock a teammate, or left behind on a server nobody rotates credentials for after it is decommissioned. Every secret in that file, a database password, an API key, a JWT signing key, is a flat text value sitting wherever the file happens to be. IBM’s Cost of a Data Breach research has repeatedly found stolen or compromised credentials among the most common ways attackers get in, and a plain text file is the easiest possible place for a credential to leak from.
Table Of Content
- Prerequisites
- Step 1: Understand the architecture before you write code
- Step 2: Create the Key Vault and grant yourself access
- Create a resource group
- Create the vault
- Grant yourself the Key Vault Secrets Officer role
- Add your secrets
- Step 3: Scaffold the Node.js project
- Step 4: Write a secrets loader with DefaultAzureCredential
- Step 5: Build the Express API that uses the secrets
- Step 6: Authenticate locally and run the app
- Step 7: Move to Azure App Service with a managed identity
- Create the App Service plan and web app
- Point the app at your vault and enable a managed identity
- Grant the app’s identity access to the vault
- Rotate a secret without redeploying
- Common mistakes and gotchas
- Getting a 403 right after creating the vault
- Using Key Vault Secrets Officer everywhere
- Passing the vault name where a URL is expected
- Assuming a cached secret updates itself
- Forgetting Node.js 18 and 20 are no longer supported
- How to verify everything works end to end
- You have a working Key Vault integration. What is next?
Azure Key Vault is Microsoft’s managed secret store. Instead of secrets living in a file next to your code, your application asks Key Vault for them at runtime over an authenticated, encrypted connection. The vault logs every access, so if something goes wrong you can see exactly which application read which secret and when, the kind of evidence an auditor or an incident responder actually wants. This tutorial teaches you how to set one up and connect a Node.js and Express API to it from scratch: what Key Vault is, how Azure’s role-based access control decides who can read your secrets, and how the same application code authenticates one way on your laptop and a different way once it is running in Azure, with zero code changes between the two.
Every Azure CLI command and Node.js SDK call in this guide is checked against Microsoft’s official documentation as it stands in July 2026, and the Express application code itself was written and run locally against real installed packages before publication, so the routes and secret-loading logic you will copy are proven to work, not just proofread. Provisioning the actual Azure resources requires your own Azure subscription (a free-tier account covers everything here), so treat every vault name, resource group name, and app name below as a placeholder you will replace with something unique to you.
Prerequisites
- An Azure account. The free tier is enough to complete every step in this guide.
- The Azure CLI installed and signed in (
az login). This guide’s commands were verified against the current Azure CLI reference documentation as of July 2026. - Node.js 22 or newer installed. Node.js 18 and 20 have both reached end of life as of 2026, so a project started today should target a currently supported line; this guide was written and tested with Node.js v24.18.0, the current Active LTS release.
- Basic familiarity with Express and REST APIs. No prior Azure experience is assumed; every concept is explained the first time it comes up.
- A bash shell. Any Linux, macOS, or WSL terminal works.
Step 1: Understand the architecture before you write code
Before touching the command line, it helps to know what you are actually building. Azure Key Vault sits behind two separate layers of access control, and the tutorial’s most common failure point is mixing them up.
The control plane governs the vault as a resource: who can create it, delete it, or see its configuration. The data plane governs the secrets inside it: who can actually read or write a secret value. Being the owner of the Azure subscription, or even the person who ran az keyvault create, only gives you control-plane access. Reading a secret out of the vault requires a separate, explicit data-plane permission grant, which is the Azure role-based access control (RBAC) step you will do in Step 2. This split exists on purpose: it means a compromised account with permission to manage infrastructure does not automatically get to read every secret in it.
The second concept is the credential chain. Your Node.js application will use a class from Microsoft’s SDK called DefaultAzureCredential. Instead of you writing separate authentication code for “running on my laptop” and “running in Azure,” DefaultAzureCredential tries a sequence of authentication methods in order and uses whichever one succeeds first. While you are developing locally, it finds and reuses your az login session. Once the same code is deployed to Azure App Service with a managed identity enabled, DefaultAzureCredential instead finds Azure’s managed identity endpoint and uses that. Your application code that asks for a secret is identical in both places; only the credential that answers underneath it changes.
Step 2: Create the Key Vault and grant yourself access
Create a resource group
A resource group is just a named container Azure uses to group related resources so you can manage or delete them together.
# Context: Azure CLI, signed in with `az login`, bash shell.
# Purpose: create a resource group to hold every resource this tutorial creates.
az group create \
--name keyvault-tutorial-rg \
--location eastus
Expected output: a JSON object describing the new resource group, ending with "provisioningState": "Succeeded". If you see that, the resource group exists and you can move on.
Create the vault
Vault names have to be globally unique across every Azure customer, not just your own subscription, because the vault gets its own DNS name. Pick something specific to you, using only letters, numbers, and hyphens, between 3 and 24 characters.
# Context: same resource group as above.
# Purpose: create the Key Vault itself.
az keyvault create \
--name your-vault-name \
--resource-group keyvault-tutorial-rg \
--location eastus
As of Key Vault API version 2026-02-01, Azure RBAC is the default access control model for every newly created vault, which is what Step 1’s data-plane explanation was building toward: creating the vault does not, by itself, let anyone read or write a secret in it, including you.
Grant yourself the Key Vault Secrets Officer role
To add and manage secrets as yourself while you develop, grant your own account the Key Vault Secrets Officer role, which Microsoft’s built-in role reference describes as permission to “perform any action on the secrets of a key vault, except manage permissions.” Scoping the role assignment to this one vault, rather than the whole subscription, means this grant cannot be used to read secrets in any other vault.
# Context: same shell session, still signed in as yourself.
# Purpose: grant your own signed-in account read/write access to secrets in this one vault.
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-tutorial-rg --query id -o tsv)
Expected output: a JSON object with "roleDefinitionName": "Key Vault Secrets Officer". Microsoft’s own Azure RBAC troubleshooting documentation notes that role assignment changes can take up to 10 minutes to take effect; if your very next command fails with a permissions error, that delay is the most likely cause, not a misconfiguration.
Add your secrets
Store a couple of example secrets so you have something real to fetch later. In a real project these would be your actual database credentials and signing keys, never values you also commit anywhere.
# Context: same vault, RBAC role assignment already propagated.
# Purpose: create two example secrets to read back from the Node.js app.
az keyvault secret set \
--vault-name your-vault-name \
--name "DB-HOST" \
--value "your-db-host.example.com"
az keyvault secret set \
--vault-name your-vault-name \
--name "JWT-SECRET" \
--value "replace-with-a-real-random-value"
Verify both secrets landed in the vault:
# Context: same vault.
# Purpose: list every secret name currently stored in the vault.
az keyvault secret list --vault-name your-vault-name --query "[].name" -o tsv
Expected output:
DB-HOST
JWT-SECRET
Step 3: Scaffold the Node.js project
With the vault side done, switch to the application. Create a new project and install the packages this tutorial uses: Express for the API, the two Azure SDK packages for talking to Key Vault, and dotenv for local environment configuration.
# Context: Node.js v22 or newer, npm installed alongside it.
# Purpose: scaffold a new Node.js project and install dependencies.
mkdir keyvault-node-tutorial && cd keyvault-node-tutorial
npm init -y
npm install express@5 @azure/keyvault-secrets @azure/identity dotenv
At the time of writing, this installs Express 5.2.1, @azure/keyvault-secrets 4.11.2, and @azure/identity 4.13.1, current versions confirmed directly against the npm registry. Express 5 became the default major version some time ago; the routes this tutorial uses are simple enough that nothing here depends on the difference from Express 4, but it is worth knowing if you go looking at Express’s own documentation and find version-specific notes.
Step 4: Write a secrets loader with DefaultAzureCredential
Create src/config/secrets.js. This module is the only place in the project that talks to Key Vault.
# Context: inside keyvault-node-tutorial/, packages from Step 3 already installed.
# Purpose: src/config/secrets.js - fetch every secret this app needs, once, at startup.
const { SecretClient } = require("@azure/keyvault-secrets");
const { DefaultAzureCredential } = require("@azure/identity");
const REQUIRED_SECRETS = ["DB-HOST", "DB-PASSWORD", "JWT-SECRET"];
async function loadSecrets() {
const vaultName = process.env.KEY_VAULT_NAME;
if (!vaultName) {
throw new Error("KEY_VAULT_NAME environment variable is not set");
}
const vaultUrl = `https://${vaultName}.vault.azure.net`;
const credential = new DefaultAzureCredential();
const client = new SecretClient(vaultUrl, credential);
const secrets = {};
for (const name of REQUIRED_SECRETS) {
const secret = await client.getSecret(name);
secrets[name] = secret.value;
}
return secrets;
}
module.exports = { loadSecrets, REQUIRED_SECRETS };
A few things worth understanding line by line. https://${vaultName}.vault.azure.net is the exact URL format Key Vault’s own SDK documentation specifies, just the vault name substituted into a fixed domain. new DefaultAzureCredential() takes no arguments here; it does not need to know in advance whether it is running on your laptop or in Azure, it works that out itself the first time something asks it for a token, using the credential chain described in Step 1. client.getSecret(name) returns an object whose .value property is the plain text secret, matching the way Azure’s own SDK samples read secrets back.
Note that this file references DB-PASSWORD, a secret this tutorial never created in Step 2. That is deliberate: keep reading to Step 6, where you will see exactly what happens, and why, when a required secret is missing.
Step 5: Build the Express API that uses the secrets
Create server.js in the project root:
# Context: same project, src/config/secrets.js already created.
# Purpose: server.js - load secrets once at startup, then serve requests.
require("dotenv").config({ quiet: true });
const express = require("express");
const { loadSecrets } = require("./src/config/secrets");
async function main() {
const secrets = await loadSecrets();
const app = express();
app.use(express.json());
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
app.get("/config-check", (req, res) => {
res.json({
dbHostConfigured: Boolean(secrets["DB-HOST"]),
jwtSecretConfigured: Boolean(secrets["JWT-SECRET"]),
});
});
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
}
main().catch((err) => {
console.error("Failed to start server:", err.message);
process.exit(1);
});
The important design decision here is that loadSecrets() is called once, inside main(), before the server starts listening. The resulting secrets stay in the secrets variable in memory for the life of the process; no route handler calls Key Vault again. This matters because Key Vault applies request throttling per vault, and because a network round trip to Key Vault on every single API request would add latency your users would actually feel. The tradeoff, an in-memory cache does not notice when you rotate a secret, is exactly what the “Rotate a secret without redeploying” section below covers.
One small honest note from actually running this: recent versions of the dotenv package print a random one-line promotional tip to the console every time .config() runs, including a plug for an unrelated third-party service. It is harmless console output from the package’s own startup banner, not something this tutorial’s code does, and passing { quiet: true } as shown above turns it off along with dotenv’s normal logging.
Step 6: Authenticate locally and run the app
Make sure you are signed in to the Azure CLI, then start the app, pointing it at your vault with an environment variable:
# Context: same terminal, Azure CLI installed.
# Purpose: confirm your local Azure CLI session, which DefaultAzureCredential will reuse.
az login
# Context: keyvault-node-tutorial/, signed in via az login.
# Purpose: run the server locally, pointed at your real vault.
KEY_VAULT_NAME=your-vault-name node server.js
Expected output: Server listening on port 3000. In a second terminal, exercise both routes:
curl http://localhost:3000/health
curl http://localhost:3000/config-check
Expected output: {"status":"ok"} from the first call. The second call is where the missing DB-PASSWORD secret from Step 4 becomes visible: because loadSecrets() tries to fetch all three required secrets and one does not exist in your vault, the whole startup call rejects before the server ever reaches app.listen(), and you will instead see Failed to start server: followed by a 404-style error naming the missing secret. That failure is the behavior to want: an application that is missing a secret it depends on should refuse to start, loudly, rather than run in a half-configured state and fail confusingly later. Add the missing secret with the same az keyvault secret set command from Step 2 and restart the app to see both routes succeed.
Step 7: Move to Azure App Service with a managed identity
Running locally with your own az login session proves the code works, but it is not how the app should authenticate once it is actually deployed. Production code should run under its own identity, not a human’s.
Create the App Service plan and web app
# Context: same resource group, same subscription.
# Purpose: create a Linux App Service plan on the cheapest paid tier, then a web app on it.
az appservice plan create \
--name keyvault-tutorial-plan \
--resource-group keyvault-tutorial-rg \
--sku B1 \
--is-linux
Before creating the web app itself, check the exact Node.js runtime identifier your installed CLI version currently accepts. Azure has changed this string’s format more than once over the years, so trust your own CLI’s output over any hardcoded value, including the one in this tutorial:
# Context: same shell.
# Purpose: list the currently supported Node.js runtime identifiers for Linux App Service.
az webapp list-runtimes --runtime node --os linux -o table
# Context: same resource group and plan.
# Purpose: create the web app itself on a current Node.js LTS runtime.
az webapp create \
--name your-app-name \
--resource-group keyvault-tutorial-rg \
--plan keyvault-tutorial-plan \
--runtime "node:24LTS"
Point the app at your vault and enable a managed identity
# Context: web app created in the previous step.
# Purpose: tell the app which vault to use, and give it its own Azure identity.
az webapp config appsettings set \
--name your-app-name \
--resource-group keyvault-tutorial-rg \
--settings KEY_VAULT_NAME=your-vault-name
az webapp identity assign \
--name your-app-name \
--resource-group keyvault-tutorial-rg
Expected output from the identity assignment includes a principalId and a tenantId. That principalId identifies the app’s own service identity in Microsoft Entra ID, separate from your own user account.
Grant the app’s identity access to the vault
The app only ever needs to read secrets, never create or delete them, so grant it the narrower Key Vault Secrets User role instead of the Secrets Officer role you gave yourself in Step 2. Giving a running application only the permissions it needs, and nothing more, is the least-privilege principle in practice, not just in theory.
# Context: same resource group, app identity already assigned.
# Purpose: capture the app's principal ID and the vault's resource ID, then grant read-only secret access.
PRINCIPAL_ID=$(az webapp identity show \
--name your-app-name \
--resource-group keyvault-tutorial-rg \
--query principalId -o tsv)
KV_ID=$(az keyvault show \
--name your-vault-name \
--resource-group keyvault-tutorial-rg \
--query id -o tsv)
az role assignment create \
--role "Key Vault Secrets User" \
--assignee-object-id "$PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--scope "$KV_ID"
Deploying your actual code onto this web app is a separate, well-documented step: az webapp up run from inside your project directory, or az webapp deploy --src-path app.zip --type zip if you would rather package it yourself, both work, and Microsoft’s own Node.js on App Service quickstart walks through either path in full. What matters for this tutorial is what happens once your code is running there: server.js and secrets.js do not change at all. DefaultAzureCredential simply resolves a different credential in its chain, a managed identity token instead of your CLI session, because it detects it is now running inside Azure.
Rotate a secret without redeploying
Because Step 5’s design caches secrets in memory at startup, updating a secret’s value in the vault does not change what a running process holds until that process restarts. Update the value, then restart the app to pick it up:
# Context: production vault and app from Step 7.
# Purpose: rotate a secret's value, then restart the app so it re-reads secrets at the next startup.
az keyvault secret set \
--vault-name your-vault-name \
--name "JWT-SECRET" \
--value "your-new-rotated-value"
az webapp restart \
--name your-app-name \
--resource-group keyvault-tutorial-rg
This is a deliberate tradeoff, not a limitation you need to work around immediately: fewer calls to Key Vault in exchange for a manual (or automated, via a deployment pipeline step) restart whenever you rotate something. If instant pickup matters more than the extra network calls for a particular secret, the fix is to skip caching that one value and call client.getSecret() for it on demand instead, or cache it with a short time-to-live and re-fetch it once that expires.
Common mistakes and gotchas
Getting a 403 right after creating the vault
This is almost always the control plane versus data plane split from Step 1. Owning or even creating the vault does not grant secret read or write access; that requires the explicit role assignment from Step 2. If you just ran the role assignment command and still get denied, give it up to 10 minutes to propagate, per Microsoft’s own Azure RBAC troubleshooting guidance, before assuming something else is wrong.
Using Key Vault Secrets Officer everywhere
It is tempting to grant the same broad role to both yourself and the app to save a step. Resist it. Step 7 deliberately uses the narrower Key Vault Secrets User role for the app’s managed identity, because the app only reads secrets; it never needs permission to create, modify, or delete them.
Passing the vault name where a URL is expected
The Azure CLI’s --vault-name flags want the bare name, like your-vault-name, but the Node.js SDK’s SecretClient constructor wants the full URL, https://your-vault-name.vault.azure.net. Mixing the two up is one of the most common first-run errors; the code in Step 4 builds the URL from the bare name for exactly this reason, so you only ever have to set the short form as an environment variable.
Assuming a cached secret updates itself
Covered in more detail above, but worth repeating as a standalone gotcha: if you update a secret’s value and nothing seems to change, check whether the process that reads it has actually restarted since the update.
Forgetting Node.js 18 and 20 are no longer supported
Plenty of existing tutorials and sample repositories still say “Node.js 18 or newer” as a prerequisite. Both Node.js 18 and Node.js 20 have reached end of life as of 2026, meaning they no longer receive security patches. Check Node.js’s own release schedule before you build anything on a version number you found in an older guide, this one included, since LTS support windows move forward every year.
How to verify everything works end to end
az keyvault secret list --vault-name your-vault-name -o tableshows both secrets you created in Step 2.az role assignment list --scope $(az keyvault show --name your-vault-name --resource-group keyvault-tutorial-rg --query id -o tsv) -o tableshows two role assignments once Step 7 is complete: your own account as Key Vault Secrets Officer, and the app’s managed identity as Key Vault Secrets User.- Locally,
curl http://localhost:3000/healthreturns{"status":"ok"}andcurl http://localhost:3000/config-checkreturns both flags astrue, proving the app reached the vault and read real values back. - After deploying to App Service, the same two routes against your app’s public URL should return the same results, this time authenticated through the managed identity rather than your CLI session, with no code difference between the two runs.
- Rotating a secret and restarting the app, as shown above, should be the only way a running app’s cached value changes; if it changes without a restart, something in your setup is not caching the way Step 5 describes.
You have a working Key Vault integration. What is next?
You now have a Node.js app that never stores a real secret in a file, a repository, or a chat message, and that authenticates the same way in code whether it is running on your laptop or inside Azure. From here, Microsoft’s own Azure Identity library documentation covers credential types this guide did not need, including user-assigned managed identities you can share across multiple apps instead of the system-assigned one used here. Key Vault’s own secret versioning and expiry attributes are worth exploring next if you want to enforce that old secret versions stop working automatically rather than relying on everyone remembering to rotate them. And if writing SDK code at all feels like more than a given project needs, App Service supports referencing Key Vault secrets directly from app settings using @Microsoft.KeyVault(...) syntax, worth comparing against the approach in this guide once you understand why the SDK approach works, so you can choose deliberately instead of by default.








No Comment! Be the first one.