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
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
| Input | Get it with |
|---|---|
| Principal object ID | Managed 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 ID | az 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) |
| Scope | The 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: kvputs 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 withRoleAssignmentUpdateNotPermitted.- The deploying identity needs
Microsoft.Authorization/roleAssignments/writeon 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.