When you build a SaaS style ASP.NET Core application that serves multiple client organizations from one Azure Blob Storage account, you need a clean way to stop one client from ever seeing another client’s files. A common pattern is to give each client its own blob container, put every user from that client into a dedicated Microsoft Entra ID security group, and grant that group RBAC read access on just that one container. This article walks through a working implementation of that pattern using Microsoft Graph, the Azure Storage Blobs SDK, and the Azure management REST API, plus the operational trade-offs you run into once this moves past a proof of concept.
This is the third post in a small series on Entra ID and blob storage from Damien Bod. The first post covers using blob storage from ASP.NET Core with Entra ID authentication, and the second covers delegated read access combined with application write access. This one builds on both to add the multi-tenant isolation layer.
Understanding the security context
The application relies on three separate Enterprise Applications, each scoped to a narrow job. One app registration is used only by the application itself to write blobs, so a signed-in user writing a file goes through the app’s own identity rather than their own delegated permissions. A second app registration is used purely to create RBAC role assignments when a new client is onboarded, since role assignment is a distinct, high-privilege operation you do not want bundled into the same identity that serves normal requests. A third app registration is the actual OpenID Connect web client that signs users in and defines the app roles used for authorization inside the application.
Splitting responsibilities this way is deliberate. If you used a single identity for everything, a bug or a compromised token in the normal request path would carry the same blast radius as your RBAC provisioning identity, which is a much bigger problem. You could also collapse the three app registrations into a single managed identity on the App Service and assign it all three sets of permissions, which removes secret management entirely, though it does concentrate all three privilege levels onto one identity.

The three steps behind onboarding a new client
Every Azure Blob Storage account here is accessed through Microsoft Entra ID rather than storage account keys, which is the recommended approach for any production workload since it lets you use RBAC and Conditional Access instead of a shared secret. The application identity gets contributor access across all containers so it can write on behalf of any client, while read access to a given container is scoped down to one security group per client. Onboarding a new client is a three step sequence: create the Entra ID security group, create the blob container, then wire an RBAC role assignment that lets the group read from that container and nothing else.
Step 1: creating the Entra ID security group
The CreateSecurityGroupAsync method below calls Microsoft Graph to create a new security group scoped to one client. It runs under application permissions through a dedicated GraphApplicationClientService, and the group name is built by stripping special characters out of the client name and appending a GUID so names stay unique even if two clients share a similar name.
using System.Text;
using Microsoft.Graph.Models;
namespace MultiClientBlobStorage.Providers.GroupUserServices;
public class ApplicationMsGraphService
{
private readonly GraphApplicationClientService _graphApplicationClientService;
public ApplicationMsGraphService(
GraphApplicationClientService graphApplicationClientService)
{
_graphApplicationClientService = graphApplicationClientService;
}
public async Task<Group?> CreateSecurityGroupAsync(string group)
{
var graphServiceClient = _graphApplicationClientService
.GetGraphClientWithClientSecretCredential();
var formatted = RemoveSpecialCharacters(group);
var groupName = $"blob-{formatted.Trim()}-{Guid.NewGuid()}".ToLower();
var requestBody = new Group
{
DisplayName = groupName,
Description = $"Security group for all users from {groupName}",
MailEnabled = false,
MailNickname = formatted,
SecurityEnabled = true
};
var result = await graphServiceClient.Groups.PostAsync(requestBody);
return result;
}
private string RemoveSpecialCharacters(string str)
{
var sb = new StringBuilder();
foreach (var c in str)
{
if (c is >= '0' and <= '9' || c is >= 'A' and <= 'Z'
|| c is >= 'a' and <= 'z' || c == '.' || c == '_')
{
sb.Append(c);
}
}
return sb.ToString();
}
}
Calling this returns a Group object with the new group’s ID, which you need in the next step to attach the RBAC role assignment. Note that MailEnabled is set to false and SecurityEnabled to true, which is what makes this a pure security group rather than a Microsoft 365 group; if you accidentally flip that, RBAC role assignment against the group’s object ID still works, but you end up with an unwanted mailbox and calendar attached to every client group. The Graph application permission needed here is Group.Create, and that is a fairly broad permission, so keep this service isolated to the onboarding workflow only.
Step 2: creating the blob container
CreateContainer uses the BlobServiceClient from the Azure.Storage.Blobs package to create a new container under the same storage account, again naming it from the client name plus a GUID. PublicAccessType.None is important here since it keeps the container private by default, meaning nobody can read from it anonymously even if they discover the container name.
private async Task<BlobContainerClient> CreateContainer(string name)
{
try
{
var formatted = RemoveSpecialCharacters(name);
string containerName = $"blob-{formatted.Trim()}-{Guid.NewGuid()}"
.ToLower();
var storage = _configuration.GetValue<string>("AzureStorage:Storage");
var credential = _clientSecretCredentialProvider
.GetClientSecretCredential();
if (storage != null && credential != null)
{
var blobServiceClient =
new BlobServiceClient(new Uri(storage), credential);
var metadata = new Dictionary<string, string?>
{
{ "name", name },
};
// Create the root container
var blobContainerClient = await blobServiceClient
.CreateBlobContainerAsync(
containerName,
PublicAccessType.None,
metadata);
if (blobContainerClient.Value.Exists())
{
Console.WriteLine(
$"Created container: {name} {blobContainerClient.Value.Name}");
}
return blobContainerClient.Value;
}
throw new Exception($"Could not create container: {name}");
}
catch (RequestFailedException e)
{
Console.WriteLine("HTTP error code {0}: {1}", e.Status, e.ErrorCode);
Console.WriteLine(e.Message);
throw;
}
}
The metadata dictionary attaches the original, human-readable client name to the container, which is useful later when you are looking at containers in the Azure portal and the GUID-suffixed container name alone tells you nothing. One thing to watch for in production is that CreateBlobContainerAsync throws a RequestFailedException with a 409 conflict if a container with that exact name already exists, so if you retry this method after a partial failure, make sure your retry logic checks for that specific status code rather than treating every exception as fatal.
Step 3: assigning RBAC read access to the group
This is the step that needs the most care, because both the security group and the blob container you just created may not be immediately visible to the Azure Resource Manager APIs. Entra ID group creation and RBAC role assignment both have eventual consistency delays, sometimes a few seconds and sometimes closer to a minute. The ApplyReaderGroupToBlobContainer method wraps the actual role assignment call in a Polly retry policy that waits three seconds between attempts, up to twenty attempts, to absorb that delay instead of failing the onboarding flow outright.
public async Task ApplyReaderGroupToBlobContainer(
BlobContainerClient blobContainer, string groupId)
{
var maxRetryAttempts = 20;
var pauseBetweenFailures = TimeSpan.FromSeconds(3);
var retryPolicy = Policy
.Handle<Exception>()
.WaitAndRetryAsync(maxRetryAttempts, i => pauseBetweenFailures);
await retryPolicy.ExecuteAsync(async () =>
{
// RBAC security group Blob data read
await _azureMgmtClientService
.StorageBlobDataReaderRoleAssignment(groupId,
blobContainer.AccountName,
blobContainer.Name);
// NOTE service principal blob write is configured on root
});
}
Twenty retries at three seconds each gives you a one-minute window, which is usually enough, but under load or during an Azure AD replication hiccup it can still fail. A more resilient version of this would queue the role assignment as a background job with a longer backoff window and retry budget, rather than blocking a web request for up to a minute waiting on it to succeed.
The actual role assignment goes through the Azure Resource Manager REST API rather than the Storage SDK, because RBAC assignment is a control-plane operation on the resource, not a data-plane operation on the blob. StorageBlobDataReaderRoleAssignment builds the request manually using an HttpClient, targeting the well-known role definition ID for Storage Blob Data Reader.
using System.Net.Http.Headers;
using System.Text.Json.Serialization;
namespace MultiClientBlobStorage.Providers.Rbac;
public class AzureMgmtClientService
{
private readonly AzureMgmtClientCredentialService _azureMgmtClientCredentialService;
private readonly IHttpClientFactory _clientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<AzureMgmtClientService> _logger;
public AzureMgmtClientService(AzureMgmtClientCredentialService azureMgmtClientCredentialService,
IHttpClientFactory clientFactory,
IConfiguration configuration,
ILogger<AzureMgmtClientService> logger)
{
_azureMgmtClientCredentialService = azureMgmtClientCredentialService;
_clientFactory = clientFactory;
_configuration = configuration;
_logger = logger;
}
// Storage Blob Data Reader role ID: 2a2b9908-6ea1-4ae2-8e65-a410df84e7d1
public async Task StorageBlobDataReaderRoleAssignment(string groupId, string storageAccountName, string blobContainerName)
{
var roleId = "2a2b9908-6ea1-4ae2-8e65-a410df84e7d1";
var roleNameUnique = $"{Guid.NewGuid()}"; // Must be a guid
var subscriptionId = _configuration["AzureMgmt:SubscriptionId"];
var servicePrincipalId = groupId;
var resourceGroupName = _configuration["AzureMgmt:ResourceGroupName"];
var objectId = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Storage/storageAccounts/{storageAccountName}/blobServices/default/containers/{blobContainerName}";
var url = $"https://management.azure.com{objectId}/providers/Microsoft.Authorization/roleAssignments/{roleNameUnique}?api-version=2022-04-01";
var client = _clientFactory.CreateClient();
var accessToken = await _azureMgmtClientCredentialService.GetAccessToken();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var roleDefinitionId = $"{objectId}/providers/Microsoft.Authorization/roleDefinitions/{roleId}";
var payloadRoleAssignment = new PayloadRoleAssignment
{
Properties = new Properties
{
RoleDefinitionId = roleDefinitionId,
PrincipalId = servicePrincipalId,
PrincipalType = "Group"
}
};
var response = await client.PutAsJsonAsync(url, payloadRoleAssignment);
if (response.IsSuccessStatusCode)
{
var responseContent = await response.Content.ReadAsStringAsync();
_logger.LogInformation("Created RBAC for read group {blobContainerName} {responseContent}", blobContainerName, responseContent);
return;
}
var responseError = await response.Content.ReadAsStringAsync();
_logger.LogCritical("Created RBAC for read group {blobContainerName} {responseError}", blobContainerName, responseError);
throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}, {responseError}");
}
private class PayloadRoleAssignment
{
[JsonPropertyName("properties")]
public Properties Properties { get; set; } = new();
}
private class Properties
{
[JsonPropertyName("roleDefinitionId")]
public string RoleDefinitionId { get; set; } = string.Empty;
[JsonPropertyName("principalId")]
public string PrincipalId { get; set; } = string.Empty;
[JsonPropertyName("principalType")]
public string PrincipalType { get; set; } = "Group";
}
}
A few things stand out in this method that are worth calling out for anyone adapting it. First, the role definition ID 2a2b9908-6ea1-4ae2-8e65-a410df84e7d1 for Storage Blob Data Reader is the same across every Azure tenant, since built-in role definitions are global, so you can hard-code it safely. Second, the identity calling this API needs Owner or User Access Administrator on the resource group, which is a significant privilege to grant to an application, and that is exactly the concern raised in the notes section further down. Third, roleNameUnique must be a GUID because Azure uses it as the name of the role assignment resource itself, and reusing a name for a second assignment on the same scope will fail with a conflict.
Wiring it together in a Razor Page
The onboarding flow is exposed through a Razor page restricted to a blob-admin-policy authorization policy, so only users with the right app role can create new clients. The page takes a client name, creates the security group, creates the container, and then applies the RBAC assignment in sequence.
[Authorize(Policy = "blob-admin-policy")]
public class CreateClientModel : PageModel
{
private readonly ClientBlobContainerProvider _clientBlobContainerProvider;
private readonly ApplicationMsGraphService _applicationMsGraphService;
[BindProperty]
public string ClientName { get; set; } = string.Empty;
public CreateClientModel(
ClientBlobContainerProvider clientBlobContainerProvider,
ApplicationMsGraphService applicationMsGraphService)
{
_clientBlobContainerProvider = clientBlobContainerProvider;
_applicationMsGraphService = applicationMsGraphService;
}
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
var group = await _applicationMsGraphService
.CreateSecurityGroupAsync(ClientName);
var blobContainer = await _clientBlobContainerProvider
.CreateBlobContainerClient(ClientName);
if (blobContainer != null && group != null && group.Id != null)
{
await _clientBlobContainerProvider
.ApplyReaderGroupToBlobContainer(blobContainer, group.Id);
}
}
return Page();
}
}
Notice this handler does not surface any error to the user if group creation or container creation fails silently and returns null; it just skips the RBAC step and returns the page. For a real onboarding flow you would want to make each step explicit, log failures clearly, and probably show the admin exactly which of the three steps succeeded so a partial failure does not leave an orphaned group with no container or a container with no RBAC applied.
Practical considerations and limitations
The biggest limitation with this approach is the privilege level it demands. The application needs Group.Create on Microsoft Graph plus Owner or User Access Administrator on the storage resource group, and that is more access than most IT and security teams are comfortable handing to an application identity, even one that is isolated into its own Enterprise Application. In many organizations this kind of provisioning would need to go through an approved infrastructure pipeline instead, using Bicep or Terraform with a service connection that only has access at deployment time, rather than a long-lived credential sitting inside a running web app.
A second consideration is that this pattern couples two different Azure APIs, Microsoft Graph and Azure Resource Manager, each with its own authentication, its own throttling behavior, and its own eventual consistency window. That is part of why the retry logic exists, and it is also why this kind of automation is not well documented anywhere as a single end-to-end recipe. If your onboarding volume is low, a handful of clients a month, doing this as a manual runbook step with an Azure CLI script might genuinely be less operational risk than automating it inside the app.
If you are considering this pattern, weigh it against Azure Storage’s native support for hierarchical namespaces with more granular POSIX-style ACLs on a Data Lake Storage Gen2 account, which can sometimes reduce the need for a container per tenant. It is also worth asking whether your compliance requirements call for per-tenant customer-managed encryption keys or cross-region replication, since neither is addressed by container-level RBAC alone and both would need to be layered on top of this design separately.
Leave a Reply