Building Azure functions that never store secrets — ever
What if your function could hit Microsoft Graph with no client secrets, no certs, and no Key Vault entries? That is exactly what a Managed identity is for: Azure issues short-lived tokens to your app on demand, and the platform rotates and protects the underlying credentials for you.
The problem: credential hell
// the old way — credential nightmare
const clientSecret = process.env.CLIENT_SECRET; // 🚨 secret in env vars
const certificatePath = './certs/app-cert.pfx'; // 🚨 cert lifecycle
const connectionString = "DefaultEndpointsProtocol=…"; // 🚨 another secret
// what goes wrong?
// - secrets expire; prod breaks
// - certificates need attention
// - secrets get committed to git (oops)
// - Key Vault becomes a hard dependency
// - drift across environments
💡 Secrets create operational drag and failure modes you do not need in Azure. Managed identity removes them.
The solution: Managed identity in one picture
At runtime your function asks the platform for a token; Azure verifies the function’s identity and returns a short-lived OAuth 2.0 access token for Microsoft Graph, which your code uses as a bearer token. No app-held secret is involved.

Minimal code: fetch a token, call graph
import type { AzureFunction, Context, HttpRequest } from "@azure/functions";
const httpTrigger: AzureFunction = async (context: Context, req: HttpRequest) => {
// IMDS pattern; works broadly, especially on VMs and newer stacks
const tokenUrl = "http://169.254.169.254/metadata/identity/oauth2/token" +
"?api-version=2018-02-01&resource=https://graph.microsoft.com/";
const tokenResp = await fetch(tokenUrl, { headers: { "Metadata": "true" } });
const { access_token } = await tokenResp.json();
const resp = await fetch("https://graph.microsoft.com/v1.0/groups", {
headers: { Authorization: `Bearer ${access_token}`, "Content-Type": "application/json" }
});
context.res = { status: resp.status, body: await resp.json() };
};
Why this is safe: the Instance Metadata Service (IMDS) lives at a non-routable address 169.254.169.254, is reachable only from inside the host, and communication to IMDS never leaves the host. The response includes expires_in (typically 3599 seconds), roughly an hour.
Production tip: on App Service/Functions prefer the local identity endpoint (
IDENTITY_ENDPOINT+X-IDENTITY-HEADER), which the runtime exposes for you. The Azure Identity SDK (DefaultAzureCredentialorManagedIdentityCredential) wraps this nicely.
// pattern for App Service/Functions local identity endpoint
const { IDENTITY_ENDPOINT, IDENTITY_HEADER } = process.env;
async function getGraphToken(): Promise<string> {
if (IDENTITY_ENDPOINT && IDENTITY_HEADER) {
const u = `${IDENTITY_ENDPOINT}?api-version=2019-08-01&resource=https://graph.microsoft.com/`;
const r = await fetch(u, { headers: { "X-IDENTITY-HEADER": IDENTITY_HEADER } });
const j = await r.json();
return j.access_token;
}
// IMDS fallback
const imds = "http://169.254.169.254/metadata/identity/oauth2/token" +
"?api-version=2018-02-01&resource=https://graph.microsoft.com/";
const r = await fetch(imds, { headers: { "Metadata": "true" } });
const j = await r.json();
return j.access_token;
}
The bootstrap: Who gives the function permission to call graph?
The Managed identity eliminates stored credentials, but it does not auto-grant API permissions. You must assign Microsoft Graph application permissions (app roles) to the function’s service principal at deploy time. Use the Graph app role assignment API and the least-privileged permissions to call it.
Required rights for the caller
Use AppRoleAssignment.ReadWrite.All plus Application.Read.All, or run under a directory role such as Cloud Application Administrator or Application Administrator. These are the least-privileged choices documented for assigning app roles.
Reliably target the Graph service principal
Do not search by display name. Address Microsoft Graph by its well-known appId and query its app roles:
servicePrincipals(appId='00000003-0000-0000-c000-000000000000')
Three IDs you need for each assignment
principalId: your function app’s managed identity service principal idresourceId: the Graph service principal idappRoleId: the specific Graph application permission you want (for example,Directory.Read.All), found in Graph’sappRolescollection
Example: assign Directory.Read.All and Group.Read.All to the function’s identity
$graphSp = az rest `
--method GET `
--uri "https://graph.microsoft.com/v1.0/servicePrincipals(appId='00000003-0000-0000-c000-000000000000')`?$select=id,appRoles" `
--query "id" -o tsv
# function's managed identity service principal id
$miSpId = $principalId # e.g., from your Bicep output
# find the appRoleIds you need programmatically
$roles = az rest --method GET `
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$graphSp?`$select=appRoles" `
--query "appRoles[?value=='Directory.Read.All' || value=='Group.Read.All'].{value:value,id:id}" -o json
# assign each role
$roles | ConvertFrom-Json | ForEach-Object {
$payload = @{ principalId = $miSpId; resourceId = $graphSp; appRoleId = $_.id } | ConvertTo-Json
$tmp = New-TemporaryFile
$payload | Out-File -FilePath $tmp -Encoding utf8
az rest --method POST `
--uri "https://graph.microsoft.com/v1.0/servicePrincipals/$miSpId/appRoleAssignments" `
--headers "Content-Type=application/json" `
--body "@$tmp" | Out-Null
Remove-Item $tmp -Force
}
Security model; what is and is not leaving your app
- No long-lived credentials in your app. The underlying SP keys and certs are platform-managed; your code never handles them
- Token requests stay local to the host. Calls to IMDS use
169.254.169.254and never leave the host - Access tokens are bearer tokens. You do receive the access token in your app; protect it like any other bearer token. The win here is that it is short-lived and you do not store a secret. The platform rotates the identity for you
Why this matters
| traditional | zero-credential (managed identity) |
|---|---|
| secrets in code/config/Key Vault | no app-held secrets; platform-issued tokens |
| manual rotation of secrets/certs | platform rotates credentials; you request new tokens when needed |
| risk of leaks via git/logs | no long-lived secrets to leak; token request is host-local |
| secret management infra | none for Graph; you manage permissions, not secrets |
| outages from expired secrets | short-lived tokens; transparent renewal via the platform |
Grab-and-go snippets
-
IMDS token request (HTTP semantics, including
expires_in):GET http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://graph.microsoft.com/with headerMetadata: true. -
App Service/Functions identity endpoint (raw HTTP example, required headers):
GET /MSI/token?resource=https://graph.microsoft.com/&api-version=2019-08-01with headerX-IDENTITY-HEADER: <IDENTITY_HEADER>to the host inIDENTITY_ENDPOINT. -
Assign Graph app role to a service principal and required permissions for the caller:
POST https://graph.microsoft.com/v1.0/servicePrincipals/{id}/appRoleAssignmentswithprincipalId,resourceId, andappRoleId; caller needsAppRoleAssignment.ReadWrite.All+Application.Read.All(or a suitable directory role).
And now, please stop using secrets if you don’t have to 😇
Published on:
Learn moreRelated posts
Unlock the power of distributed graph databases with JanusGraph and Azure Apache Cassandra
Connecting the Dots: How Graph Databases Drive Innovation In today’s data-rich world, organizations face challenges that go beyond simple tabl...
Azure Boards integration with GitHub Copilot
A few months ago we introduced the Azure Boards integration with GitHub Copilot in private preview. The goal was simple: allow teams to take a...
Microsoft Dataverse – Monitor batch workloads with Azure Monitor Application Insights
We are announcing the ability to monitor batch workload telemetry in Azure Monitor Application Insights for finance and operations apps in Mic...
Copilot Studio: Connect An Azure SQL Database As Knowledge
Copilot Studio can connect to an Azure SQL database and use its structured data as ... The post Copilot Studio: Connect An Azure SQL Database ...
Retirement of Global Personal Access Tokens in Azure DevOps
In the new year, we’ll be retiring the Global Personal Access Token (PAT) type in Azure DevOps. Global PATs allow users to authenticate across...
Azure Cosmos DB vNext Emulator: Query and Observability Enhancements
The Azure Cosmos DB Linux-based vNext emulator (preview) is a local version of the Azure Cosmos DB service that runs as a Docker container on ...
Azure Cosmos DB : Becoming a Search-Native Database
For years, “Database” and “Search systems” (think Elastic Search) lived in separate worlds. While both Databases and Search Systems oper...