Luise Freese

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.

mi-sequence diagram

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 (DefaultAzureCredential or ManagedIdentityCredential) 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 id
  • resourceId: the Graph service principal id
  • appRoleId: the specific Graph application permission you want (for example, Directory.Read.All), found in Graph’s appRoles collection

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.254 and 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

traditionalzero-credential (managed identity)
secrets in code/config/Key Vaultno app-held secrets; platform-issued tokens
manual rotation of secrets/certsplatform rotates credentials; you request new tokens when needed
risk of leaks via git/logsno long-lived secrets to leak; token request is host-local
secret management infranone for Graph; you manage permissions, not secrets
outages from expired secretsshort-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 header Metadata: true.

  • App Service/Functions identity endpoint (raw HTTP example, required headers): GET /MSI/token?resource=https://graph.microsoft.com/&api-version=2019-08-01 with header X-IDENTITY-HEADER: <IDENTITY_HEADER> to the host in IDENTITY_ENDPOINT.

  • Assign Graph app role to a service principal and required permissions for the caller: POST https://graph.microsoft.com/v1.0/servicePrincipals/{id}/appRoleAssignments with principalId, resourceId, and appRoleId; caller needs AppRoleAssignment.ReadWrite.All + Application.Read.All (or a suitable directory role).

And now, please stop using secrets if you don’t have to 😇

You May Also Like

Secretless cross-tenant dataverse access

Secretless cross-tenant dataverse access

Call Dataverse in Tenant B from Azure Functions in Tenant A without storing secrets or certificates; use a user-assigned managed identity and a federated identity credential. The app is multitenant …

Want to work with me?