Mert Özen Further With Every Line
DevOps Cloud Fundamentals — #03

Identity and Access in the Cloud: Entra ID, RBAC, Managed Identity and Apps Without Access Keys

Mert Özen Nov 15, 2026 14 min 7 views
Identity and Access in the Cloud: Entra ID, RBAC, Managed Identity and Apps Without Access Keys

An app without access keys gets short-lived tokens from Entra ID through a managed identity and holds a data role only at the scope it needs. We cover the control plane versus data plane, Azure RBAC's principal-role-scope model, the managed identity's local token endpoint with an imitation service, the real output of the DefaultAzureCredential chain, Bookstore.Api's key-free Blob client and the role granted on the covers container.

Version note: The examples in this post were run with .NET 10 (SDK 10.0.404), Azure.Identity 1.21.0, Azure.Storage.Blobs 12.30.1, xUnit v3 4.0.3 and Azure CLI 2.92.0. No Azure resources or role assignments were created; the managed identity token endpoint was simulated locally with a fake service, and the behavior on Azure is taken from Microsoft Learn (November 2026).

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, book covers live in a storage account, in the covers container. You gave Bookstore.Api's managed identity the Contributor role on the resource group that holds the storage account; Contributor is known as the role that "can change everything". Can the application read a blob in covers with an Entra ID token? Second question: when you take the role away tomorrow, does the application lose access immediately? The answers are in the post.

Control Plane and Data Plane

In Azure you talk to a resource in two different ways. The control plane manages the resource itself: creating a storage account, changing a setting, adding a container. These requests go to management.azure.com, the Azure Resource Manager we saw in post 2. The data plane works with the data inside the resource: uploading a blob, writing a message to a queue, running a query in a database. These requests go straight to the service's endpoint, such as stbookstoredevweu.blob.core.windows.net.

In Azure RBAC, a role definition keeps these two worlds in separate fields: Actions lists control plane operations and DataActions lists data plane operations. The Actions list of the Contributor role covers almost everything, but its DataActions list is empty. That's why the answer to the guess is no: Contributor can't read a blob with an Entra ID token; that needs a data role such as "Storage Blob Data Reader" or "Storage Blob Data Contributor". But there is a catch: Contributor can list the storage account's access keys, which is a control plane operation. And anyone holding a key reaches all of the data without going through RBAC at all. So "I didn't grant a data role, therefore it can't reach the data" isn't true unless shared key access is disabled.

Azure RBAC and Identity Types

A role assignment has three parts: who (the security principal), what it may do (the role definition) and where (the scope). The scope can be any level of the tree from post 2: a management group, a subscription, a resource group, a single resource, even a single container in storage. The assignment applies at that scope and everything below it. The most used built-in roles are Reader (only reads), Contributor (manages but can't hand out access) and Owner (also hands out access). If you want to delegate access management separately, the "Role Based Access Control Administrator" role grants only the right to manage role assignments. In practice the principle of least privilege comes down to two rules: grant data roles at the narrowest possible scope, and give Owner not to individuals but to a group of a few administrators.

A security principal comes in four kinds. Users and groups are the people in Entra ID; granting roles to groups rather than individuals makes the cleanup easier when someone leaves the team. A service principal is an application's identity in Entra ID with its own secret or certificate; storing and rotating that secret is your job. A managed identity is a service principal that Azure manages for you: it has no secret, and only the resource running in Azure can get its token. A system-assigned managed identity belongs to the resource and is deleted with it; a user-assigned one is a separate resource, can be attached to several applications, and keeps its identity and role assignments even if you recreate the application. In Bookstore we use the user-assigned id-bookstore-dev-weu. The last kind is workload identity federation: a job running outside Azure, such as a GitHub Actions job, exchanges a token from its own provider for an Entra ID token. The post "GitHub Actions ile Deploy 1: OIDC ile Azure App Service ve Şifresiz Cloud Erişimi" in the GitHub Actions series builds this flow step by step.

AzureAWS equivalentNote
Entra ID user and groupIAM Identity Center user, IAM user and groupPeople; SSO is preferred over long-lived access keys
Role definitionIAM policyOn AWS permissions live in the policy document, a role is a separate identity
Role assignment (principal, role, scope)Attaching a policy to a user, group or roleOn AWS the scope is given by the Resource field of the policy
Managed identityEC2 instance profile, ECS task role, Lambda execution roleSecretless temporary credentials provided by the platform
Workload identity federationIAM OIDC identity provider and AssumeRoleWithWebIdentityExchanging an external system's token for a cloud identity

Inside a Managed Identity: The Local Token Endpoint

There is a simple mechanism behind a managed identity. App Service and Functions give an application with a managed identity two environment variables: IDENTITY_ENDPOINT, a local token service only that application can reach, and IDENTITY_HEADER, a value the platform rotates and that is sent with every request to protect against SSRF, an attacker steering the application into making internal requests on their behalf. The application sends a GET request to that address and gets a short-lived Entra ID token back. You don't need Azure to see this; we wrote a local imitation that behaves according to the protocol described on Learn. It doesn't produce real tokens; it only shows the request:

// Local stand-in for App Service's token endpoint (IDENTITY_ENDPOINT), only to see the protocol.
// It returns a fake token; nothing here talks to Microsoft Entra ID.
// Run: dotnet run tools/cloud/fake-identity-endpoint.cs
#:sdk Microsoft.NET.Sdk.Web
#:property PublishAot=false

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:5051");
builder.Logging.SetMinimumLevel(LogLevel.Warning);
WebApplication app = builder.Build();

const string ExpectedHeader = "local-test-header";

app.MapGet("/msi/token", (HttpRequest request) =>
{
    Console.WriteLine($"GET {request.Path}{request.QueryString}");
    Console.WriteLine($"  X-IDENTITY-HEADER present: {request.Headers.ContainsKey("X-IDENTITY-HEADER")}");
    if (request.Headers["X-IDENTITY-HEADER"] != ExpectedHeader)
    {
        return Results.Unauthorized();
    }
    long expiresOn = DateTimeOffset.UtcNow.AddHours(24).ToUnixTimeSeconds();
    return Results.Json(new Dictionary<string, string>
    {
        ["access_token"] = "fake-token-for-local-test",
        ["expires_on"] = expiresOn.ToString(),
        ["resource"] = request.Query["resource"].ToString(),
        ["token_type"] = "Bearer",
        ["client_id"] = request.Query["client_id"].ToString(),
    });
});

app.Run();

Opposite it we put a small application that makes the call Bookstore.Api will make. It asks for a token for Storage and prints what each credential did:

// Asks Microsoft Entra ID for a Storage token the way Bookstore.Api does and prints what happened.
// Run: dotnet run tools/cloud/identity-probe.cs -- [default|managed]
#:package Azure.Identity

using Azure.Core;
using Azure.Identity;

string mode = args.Length > 0 ? args[0] : "default";
string? clientId = Environment.GetEnvironmentVariable("BOOKSTORE_IDENTITY_CLIENT_ID");

TokenCredential credential = mode == "managed"
    ? new ManagedIdentityCredential(clientId is null ? ManagedIdentityId.SystemAssigned : ManagedIdentityId.FromUserAssignedClientId(clientId))
    : new DefaultAzureCredential();

TokenRequestContext request = new(["https://storage.azure.com/.default"]);
try
{
    AccessToken token = await credential.GetTokenAsync(request, CancellationToken.None);
    Console.WriteLine($"{credential.GetType().Name}: token received, {token.Token.Length} chars, expires {token.ExpiresOn:u}");
}
catch (AuthenticationFailedException ex)
{
    Console.WriteLine($"{credential.GetType().Name} failed:");
    Console.WriteLine(ex.Message);
}

File-based apps turn on Native AOT analysis by default and the repo's Directory.Build.props turns warnings into errors, so the imitation service, which produces JSON through reflection, needs the PublishAot=false line. Start the imitation in one terminal and run the probe in a second terminal with the environment variables App Service would provide:

$ export IDENTITY_ENDPOINT=http://127.0.0.1:5051/msi/token
$ export IDENTITY_HEADER=local-test-header
$ export BOOKSTORE_IDENTITY_CLIENT_ID=00000000-0000-0000-0000-000000000000
$ dotnet run tools/cloud/identity-probe.cs -- managed
ManagedIdentityCredential: token received, 25 chars, expires 2026-11-15 19:45:31Z

The incoming request shows up on the imitation's console:

GET /msi/token?api-version=2019-08-01&resource=https://storage.azure.com&client_id=00000000-0000-0000-0000-000000000000
  X-IDENTITY-HEADER present: True

Two details to notice. The code asks for the scope https://storage.azure.com/.default, but what goes to the endpoint is resource=https://storage.azure.com: Azure.Identity converts it to the older form the managed identity protocol expects. Second, client_id: with a user-assigned identity you have to say which one you want with this parameter; without it the request goes to the system-assigned identity and fails if there isn't one. That's why Bookstore.Api reads the client ID from configuration.

Reading DefaultAzureCredential

Most examples start with new DefaultAzureCredential(). It's a ready-made chain that tries many credentials in order. Running the probe in default mode on a machine with no session signed in shows the whole chain:

$ dotnet run tools/cloud/identity-probe.cs
DefaultAzureCredential failed:
DefaultAzureCredential failed to retrieve a token from the included credentials. See the troubleshooting guide for more information. https://aka.ms/azsdk/net/identity/defaultazurecredential/troubleshoot
- EnvironmentCredential authentication unavailable. Environment variables are not fully configured. See the troubleshooting guide for more information. https://aka.ms/azsdk/net/identity/environmentcredential/troubleshoot
- WorkloadIdentityCredential authentication unavailable. The workload options are not fully configured. See the troubleshooting guide for more information. https://aka.ms/azsdk/net/identity/workloadidentitycredential/troubleshoot
- ManagedIdentityCredential authentication unavailable. No response received from the managed identity endpoint.
- VisualStudioCredential authentication failed: Visual Studio Token provider can't be accessed at /home/user/.IdentityService/AzureServiceAuth/tokenprovider.json
- VisualStudioCodeCredential requires the Azure.Identity.Broker package to be referenced from the project. VS Code Azure See the troubleshooting guide for more information. https://aka.ms/azsdk/net/identity/vscodecredential/troubleshoot
- AzureCliCredential authentication failed: Azure CLI not installed
- AzurePowerShellCredential authentication failed: PowerShell is not installed.
- AzureDeveloperCliCredential authentication failed: Azure Developer CLI could not be found.
- The BrokerCredential requires the Azure.Identity.Broker package to be referenced. See the troubleshooting guide for more information. https://aka.ms/azsdk/net/identity/brokercredential/troubleshoot

The order matters: environment variables first, then workload identity, then managed identity, and developer tools last (Visual Studio, VS Code, az, PowerShell, azd). If you ran az login on your machine, the chain stops at AzureCliCredential; on App Service the managed identity is found in the third step. This flexibility is convenient for development but a risk in production: the Azure SDK for .NET's own guidance recommends using a specific credential such as ManagedIdentityCredential instead of DefaultAzureCredential in a deployed application. The reasons are harder debugging, every attempt adding latency, and the application silently switching to another identity if someone puts a variable such as AZURE_CLIENT_SECRET on the server. If you want to keep the chain, you can narrow it with the AZURE_TOKEN_CREDENTIALS environment variable set to prod, dev or a single credential name.

Bookstore.Api: A Storage Client Without Access Keys

We apply this rule to Bookstore.Api. The cover upload endpoint is the subject of post 7; here we only decide how the client is built. The settings live in one section:

namespace Bookstore.Storage;

public sealed class BlobStorageOptions
{
    public const string SectionName = "BlobStorage";

    // Azure: https://<account>.blob.core.windows.net, used with a managed identity
    public Uri? ServiceUri { get; init; }

    // Client ID of the user-assigned managed identity; empty means system-assigned
    public string? ManagedIdentityClientId { get; init; }

    // Local development only (Azurite); never set in Azure
    public string? ConnectionString { get; init; }

    public string CoversContainer { get; init; } = "covers";
}
using Azure.Core;
using Azure.Identity;
using Azure.Storage.Blobs;

namespace Bookstore.Storage;

public static class BlobStorageExtensions
{
    public static IServiceCollection AddBlobStorage(this IServiceCollection services, IConfiguration configuration)
    {
        BlobStorageOptions options = configuration.GetSection(BlobStorageOptions.SectionName).Get<BlobStorageOptions>() ?? new();
        services.AddSingleton(options);
        // Created on first use, so the API still starts when storage isn't configured
        services.AddSingleton(_ => CreateServiceClient(options));
        services.AddSingleton(sp => sp.GetRequiredService<BlobServiceClient>().GetBlobContainerClient(options.CoversContainer));
        return services;
    }

    public static BlobServiceClient CreateServiceClient(BlobStorageOptions options)
    {
        if (options.ConnectionString is { Length: > 0 } connectionString)
        {
            return new BlobServiceClient(connectionString);
        }

        Uri serviceUri = options.ServiceUri
            ?? throw new InvalidOperationException("BlobStorage:ServiceUri is missing (see README.md).");
        return new BlobServiceClient(serviceUri, CreateCredential(options));
    }

    // Azure: always the user-assigned identity, no fallback chain.
    // Developer machine: DefaultAzureCredential picks up the az login / Visual Studio account.
    public static TokenCredential CreateCredential(BlobStorageOptions options) =>
        options.ManagedIdentityClientId is { Length: > 0 } clientId
            ? new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(clientId))
            : new DefaultAzureCredential();
}

On Azure ManagedIdentityClientId is set, so there is no chain; the user-assigned identity is used directly. On a developer machine there are two options: connect to the local Azurite emulator with "ConnectionString": "UseDevelopmentStorage=true" in appsettings.Development.json, or leave the client ID empty and go to a real dev storage account with your own az login account. One line changed in Program.cs:

builder.Services
    .AddCatalogModule()
    .AddInventoryModule()
    .AddOrderingModule()
    .AddBlobStorage(builder.Configuration);

The most important property of this code can be verified with a test. BlobServiceClient.CanGenerateAccountSasUri in Azure.Storage.Blobs tells you whether the client carries an account key; a client with a key can produce SAS URLs for the whole account on its own, without any RBAC check:

using Azure.Identity;
using Azure.Storage.Blobs;
using Bookstore.Storage;

namespace Bookstore.Api.Tests;

public class BlobStorageTests
{
    private static readonly Uri ServiceUri = new("https://stbookstoredevweu.blob.core.windows.net");

    [Fact]
    public void Managed_identity_client_has_no_account_key()
    {
        BlobStorageOptions options = new() { ServiceUri = ServiceUri, ManagedIdentityClientId = "00000000-0000-0000-0000-000000000000" };

        BlobServiceClient client = BlobStorageExtensions.CreateServiceClient(options);

        Assert.IsType<ManagedIdentityCredential>(BlobStorageExtensions.CreateCredential(options));
        Assert.False(client.CanGenerateAccountSasUri);
    }

    [Fact]
    public void Connection_string_client_holds_the_account_key()
    {
        BlobStorageOptions options = new() { ConnectionString = "UseDevelopmentStorage=true" };

        BlobServiceClient client = BlobStorageExtensions.CreateServiceClient(options);

        Assert.True(client.CanGenerateAccountSasUri);
    }

    [Fact]
    public void Missing_service_uri_fails_with_a_clear_message()
    {
        InvalidOperationException ex = Assert.Throws<InvalidOperationException>(
            () => BlobStorageExtensions.CreateServiceClient(new BlobStorageOptions()));

        Assert.Contains("BlobStorage:ServiceUri", ex.Message);
    }
}
$ dotnet test
Test run summary: Passed!
  total: 13
  failed: 0
  succeeded: 13
  skipped: 0
  duration: 2s 478ms

Ten of the tests are the catalog and ordering tests from the reference application; three are new. The tests run against a SQL Server container as described in the reference application's README.

Granting Access: A Role Assignment at Container Scope

The Azure side is a script. It creates the storage account with shared key access disabled, creates the identity and grants the data role only on the covers container:

#!/usr/bin/env bash
# Bookstore.Api identity: a user-assigned managed identity that may only read and write the covers container.
set -euo pipefail

: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
ENVIRONMENT="${1:?Usage: 03-identity.sh <dev|staging|prod>}"
LOCATION="westeurope"
RG="rg-bookstore-${ENVIRONMENT}-weu"
IDENTITY="id-bookstore-${ENVIRONMENT}-weu"
STORAGE="stbookstore${ENVIRONMENT}weu"

az account set --subscription "$AZURE_SUBSCRIPTION_ID"

# Storage account that accepts only Microsoft Entra ID: no account key, no anonymous access
az storage account create \
  --name "$STORAGE" --resource-group "$RG" --location "$LOCATION" \
  --sku Standard_LRS --kind StorageV2 --min-tls-version TLS1_2 \
  --allow-shared-key-access false --allow-blob-public-access false

# Control plane: creating the container goes through ARM, no data role needed
az storage container-rm create --storage-account "$STORAGE" --name covers

az identity create --name "$IDENTITY" --resource-group "$RG" --location "$LOCATION"
PRINCIPAL_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query principalId --output tsv)
CLIENT_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query clientId --output tsv)
STORAGE_ID=$(az storage account show --name "$STORAGE" --resource-group "$RG" --query id --output tsv)

# Data plane: the narrowest scope is the container, not the account or the resource group
az role assignment create \
  --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Contributor" \
  --scope "${STORAGE_ID}/blobServices/default/containers/covers"

echo "BlobStorage__ServiceUri=https://${STORAGE}.blob.core.windows.net"
echo "BlobStorage__ManagedIdentityClientId=${CLIENT_ID}"

There are three decisions. --allow-shared-key-access false closes the path of Contributor listing keys and bypassing RBAC; from now on this account is reachable only with an Entra ID token. az storage container-rm create, which creates the container, works through ARM, so whoever runs the script doesn't need a data role; this is what separate control and data planes mean in practice. Finally, the role assignment is at the .../blobServices/default/containers/covers scope: Bookstore.Api can't read the exports container that will be added to the same account later. We checked the script with ShellCheck but did not run it against Azure.

The second answer to the guess is here too. According to Learn, the back-end services for managed identities can cache tokens per resource for around 24 hours, and there is no way to force a token refresh earlier. So when you remove a role, the application may not lose access right away; in an emergency the faster path is to cut access on the resource side (a network rule, deleting the container or detaching the identity from the application).

Account Security: MFA, Break-Glass and PIM

Application identities can be secretless, but human accounts are still the biggest risk. Microsoft made MFA mandatory for Azure: the first phase, from October 2024, covers the Azure portal and admin centers; the second phase, from 1 October 2025, covers create, update and delete operations through Azure CLI, PowerShell, IaC tools and the REST API. Workload identities such as managed identities and service principals aren't affected; if an automation still runs with a user account, now is the time to move it to a workload identity.

Break-glass accounts are one or two accounts set aside to get into the tenant when the normal admin accounts can't be used, for example when a Conditional Access rule was written wrong. They sign in with MFA too; Microsoft recommends passkeys (FIDO2) or certificate-based authentication. They should raise an alert whenever they are used. PIM defines roles such as Owner as "eligible" instead of assigning them permanently: when Ayşe needs to intervene in prod, she activates the role for a few hours with a justification, and the role drops off by itself when the work is done. PIM requires a paid Entra ID license.

In Production

Review role assignments like code. Dump who holds which role at which scope regularly with az role assignment list --scope ... --include-inherited; without seeing inherited assignments you can't say "this person has no access to prod".

Manage the user-assigned identity independently of the application. When you delete and recreate Bookstore.Api, the identity and its role assignments stay in place; a system-assigned identity comes back as a new identity and you have to redo every role assignment.

After disabling shared keys, check that everything works with Entra ID. An old reporting job, a portal extension or a saved connection in Azure Storage Explorer may still use the key; they start getting 403 on the day the key is turned off.

Common Pitfalls

Giving developers Owner at the subscription level. Owner can delete everything and hand that power to others. For daily work a developer needs Contributor on the dev resource group and narrowly scoped data roles if necessary; for prod, time-limited access through PIM.

Writing the connection string or access key in plain text in code or in an app setting. A key doesn't say who owns it, doesn't expire, and stays with whoever copied it. If moving to a managed identity isn't possible, at least use a Key Vault reference; the details are in the Configuration ve Secret Management series.

Reading "granted Contributor" as "has no access to the data". With shared key enabled, Contributor fetches the key and reads everything.

Using a user-assigned identity without passing its client ID. When DefaultAzureCredential or ManagedIdentityCredential is called without a client ID, it looks for the system-assigned identity; if the application has only a user-assigned one, no token is obtained.

A Small Experiment

★☆☆ Run the imitation service and run the probe with a wrong IDENTITY_HEADER value. Note which error the credential reports and what you see on the imitation's console.

★★☆ Add Azure.Core.Diagnostics.AzureEventSourceListener to the probe and write the Azure-Identity events to the console; measure how long each step of the chain takes in default mode. How does the time change with AZURE_TOKEN_CREDENTIALS=dev?

★★★ Build an access matrix for Bookstore: which role at which scope should Selin, Emre, Ayşe, the GitHub Actions deploy identity and Bookstore.Api's managed identity have? For each row, write one sentence on why a broader role would be wrong.

In the next post we decide where Bookstore runs: the difference between VMs, App Service, Container Apps, AKS and Functions, their AWS equivalents, and how long a cold start of Bookstore.Api really takes.