Assign Azure roles from the CLI, Bicep and TypeScript — idempotently

Grant a principal a role at the narrowest scope so the assignment works the first time and survives redeployment — find the principal's object ID, pick the role by ID, assign with az (assignee-object-id and principal type), with Bicep (Microsoft.Authorization/roleAssignments@2022-04-01 and guid() names) or with @azure/arm-authorization, then verify effective access, including inherited and group assignments.

markdownguidev1447T-DE9Zstage: buildtrust: officialservice: rbacinterface: cliinterface: iacinterface: sdkaz role assignment createassignee-object-idassignee-principal-typeMicrosoft.Authorization/roleAssignments@2022-04-01Bicep role assignment guidroleDefinitionId subscriptionResourceIdexisting resource scope BicepAuthorizationManagementClient roleAssignments.create@azure/arm-authorizationaz role assignment list --all --include-inherited --include-groupsleast privilege scopeRoleAssignmentExists idempotentcheck access Azure

Created 21 Oct 2026 · Modified 23 Oct 2026 · 1 version

Assign Azure roles — idempotently

Use the principal's object ID, the role's definition ID, the narrowest scope, and a name derived from all three. That combination avoids every common assignment failure: directory lookups, replication delays, duplicate assignments and RoleAssignmentUpdateNotPermitted.

1. Collect the three inputs

InputGet it with
Principal object IDManaged identity: az identity show -n <id> -g <rg> --query principalId -o tsv (system-assigned: az webapp identity show … --query principalId). Service principal: az ad sp show --id <client-id> --query id -o tsv. Group: az ad group show --group <name> --query id -o tsv
Role definition IDaz role definition list --name "Key Vault Secrets User" --query "[0].name" -o tsv (the GUID; built-in IDs are listed in reference/rbac/role-assignments-and-scopes.md)
ScopeThe target resource's id: az keyvault show -n <kv> --query id -o tsv

2. Azure CLI

az role assignment create \
  --assignee-object-id <object-id> \
  --assignee-principal-type ServicePrincipal \
  --role 4633458b-17de-408a-b874-0445c86b69e6 \
  --scope <resource-id>

--assignee <name-or-upn> makes the CLI look the principal up in Microsoft Graph first, which fails for service principal callers without directory read rights (Insufficient privileges to complete the operation) and for principals that haven't replicated yet. --assignee-object-id with --assignee-principal-type skips both. Use User, Group or ServicePrincipal (managed identities are service principals).

3. Bicep

param principalId string
param keyVaultName string

var secretsUserRoleId = '4633458b-17de-408a-b874-0445c86b69e6'

resource kv 'Microsoft.KeyVault/vaults@2026-05-15' existing = {
  name: keyVaultName
}

resource kvSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(kv.id, principalId, secretsUserRoleId)
  scope: kv
  properties: {
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', secretsUserRoleId)
    principalId: principalId
    principalType: 'ServicePrincipal'
  }
}
  • scope: kv puts the assignment on the vault, not the resource group.
  • guid(scope, principal, role) is stable across redeploys and changes when any input changes, so a recreated identity gets a new assignment instead of failing with RoleAssignmentUpdateNotPermitted.
  • The deploying identity needs Microsoft.Authorization/roleAssignments/write on that scope: Contributor isn't enough.

4. TypeScript

import { createHash } from "node:crypto";
import { DefaultAzureCredential } from "@azure/identity";
import { AuthorizationManagementClient } from "@azure/arm-authorization";

// Deterministic GUID from the three inputs, like Bicep's guid().
function assignmentName(scope: string, principalId: string, roleId: string): string {
  const h = createHash("sha256").update(`${scope}|${principalId}|${roleId}`).digest("hex");
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
}

export async function grant(subscriptionId: string, scope: string, principalId: string, roleId: string) {
  const client = new AuthorizationManagementClient(new DefaultAzureCredential(), subscriptionId);
  try {
    await client.roleAssignments.create(scope, assignmentName(scope, principalId, roleId), {
      roleDefinitionId: `/subscriptions/${subscriptionId}/providers/Microsoft.Authorization/roleDefinitions/${roleId}`,
      principalId,
      principalType: "ServicePrincipal",
    });
  } catch (e) {
    // Same principal, role and scope already assigned under another name: treat as done.
    if ((e as { code?: string }).code !== "RoleAssignmentExists") throw e;
  }
}

The REST error is "code": "RoleAssignmentExists", "message": "The role assignment already exists." (seen in a Microsoft Q&A thread); the SDK's RestError.code carrying it is the hub's reading, so log e once to confirm.

5. Verify effective access

az role assignment list --assignee <object-id> --all --include-inherited --include-groups \
  --query "[].{role:roleDefinitionName, scope:scope}" -o table

--all searches every scope in the subscription; --include-inherited adds assignments from parent scopes and --include-groups those through groups. A change can take about 10 minutes to apply. If access still fails, guides/identity/fix-access-denied.md goes through the other layers (deny assignments, policy, network, data-plane models).

Next

Errors: reference/rbac/errors.md. The model and built-in role IDs: reference/rbac/role-assignments-and-scopes.md.

Read this document as markdown — the same bytes an agent receives.