Save Uploaded Files To Azure Blob Storage In ASP.NET Core

In my case, I had an ASP.NET Core MVC app where Step Two of onboarding accepted files, but only saved a database row. I needed the upload to actually land in Azure Blob Storage, under a folder named after the client uid.

This post is the exact sequence I followed.

1. Add the Azure Storage packages

Start with the SDK packages for blobs and Azure identity.

1
2
3
4
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.14.2" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.25.1" />
</ItemGroup>

Azure.Storage.Blobs gives you BlobContainerClient and BlobClient. Azure.Identity gives you DefaultAzureCredential, which is useful when you want the same code to work locally and in Azure.

Docs: Azure Storage Blobs for .NETAzure Identity for .NET

2. Add storage configuration

I kept the blob settings in a dedicated config section so the upload code only cares about IOptions<AzureBlobStorageOptions>.

1
2
3
4
5
6
7
8
9
10
11
12
namespace Chatbot.Web.Infrastructure.Storage;

public sealed class AzureBlobStorageOptions
{
public const string SectionName = "AzureBlobStorage";

public string? ConnectionString { get; init; }

public string? AccountName { get; init; }

public string? ContainerName { get; init; }
}

Then bind it in Program.cs and register the upload service.

1
2
3
4
builder.Services.Configure<AzureBlobStorageOptions>(
builder.Configuration.GetSection(AzureBlobStorageOptions.SectionName));

builder.Services.AddSingleton<IClientFileStorage, AzureBlobClientFileStorage>();

My development config looked like this. I used a real connection string locally, but keep secrets out of source control and swap in your own values.

1
2
3
4
5
6
7
{
"AzureBlobStorage": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=<account-name>;AccountKey=<account-key>;EndpointSuffix=core.windows.net",
"AccountName": "<account-name>",
"ContainerName": "chatbot-files"
}
}

If you already have a connection string, AccountName becomes optional for local runs. I still left it there because the same options class also supports identity-based auth.

Docs: Options pattern in ASP.NET CoreConfiguration in ASP.NET Core

3. Create a blob storage service

I wrapped the Azure SDK in a small interface so the controller only knows it is uploading a file for a client.

1
2
3
4
5
6
7
8
9
public interface IClientFileStorage
{
Task UploadAsync(
Guid clientUid,
string fileName,
Stream content,
string? contentType,
CancellationToken cancellationToken = default);
}

The implementation does two things:

  1. Uses the connection string when one is configured.
  2. Falls back to DefaultAzureCredential when it is not.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
using Azure;
using Azure.Identity;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Options;

namespace Chatbot.Web.Infrastructure.Storage;

public sealed class AzureBlobClientFileStorage : IClientFileStorage
{
private readonly BlobContainerClient containerClient;

public AzureBlobClientFileStorage(IOptions<AzureBlobStorageOptions> options)
{
var settings = options.Value;
var containerName = settings.ContainerName?.Trim();
if (string.IsNullOrWhiteSpace(containerName))
{
throw new InvalidOperationException("Azure Blob Storage container name is not configured.");
}

if (!string.IsNullOrWhiteSpace(settings.ConnectionString))
{
containerClient = new BlobContainerClient(settings.ConnectionString, containerName);
return;
}

var accountName = settings.AccountName?.Trim();
if (string.IsNullOrWhiteSpace(accountName))
{
throw new InvalidOperationException("Azure Blob Storage account name is not configured.");
}

var serviceUri = new Uri($"https://{accountName}.blob.core.windows.net");
var serviceClient = new BlobServiceClient(serviceUri, new DefaultAzureCredential());
containerClient = serviceClient.GetBlobContainerClient(containerName);
}

public async Task UploadAsync(Guid clientUid, string fileName, Stream content, string? contentType, CancellationToken cancellationToken = default)
{
var sanitizedFileName = Path.GetFileName(fileName).Trim();
if (string.IsNullOrWhiteSpace(sanitizedFileName))
{
throw new InvalidOperationException("File name is required.");
}

var blobClient = containerClient.GetBlobClient($"{clientUid:D}/{sanitizedFileName}");

try
{
await blobClient.UploadAsync(
content,
new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders
{
ContentType = string.IsNullOrWhiteSpace(contentType)
? "application/octet-stream"
: contentType
}
},
cancellationToken);
}
catch (RequestFailedException exception) when (exception.ErrorCode == BlobErrorCode.BlobAlreadyExists)
{
throw new InvalidOperationException(
"A file with the same name already exists for this client.",
exception);
}
}
}

The important bit is this line:

1
var blobClient = containerClient.GetBlobClient($"{clientUid:D}/{sanitizedFileName}");

That gives you a blob path like 1ee45be0-202d-4c2b-91ba-2eb7362dcf52/my-file.pdf. Azure Blob Storage does not have real folders, but that prefix behaves like one in the portal and SDK.

Docs: BlobContainerClientUpload blobs with .NETDefaultAzureCredential

4. Upload the file before inserting the database row

My original bug was simple: the app inserted metadata, but never persisted the file itself. The fix was to make blob upload the gate before InsertAsync(...).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
[HttpPost("Onboarding/StepTwo/{uid:guid}/Upload")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UploadFile(
Guid uid,
string group,
IFormFile file,
CancellationToken cancellationToken)
{
var client = await clientRepository.GetByUidAsync(uid, cancellationToken);
if (client is null)
{
TempData["OnboardingMessage"] = "Client not found.";
return RedirectToAction(nameof(StepOne));
}

if (file is null || file.Length == 0)
{
TempData["OnboardingMessage"] = "Please choose a file to upload.";
return RedirectToAction(nameof(StepTwo), new { uid });
}

var fileTypeDescription = ResolveFileTypeDescription(group);
if (fileTypeDescription is null)
{
TempData["OnboardingMessage"] = "Unsupported file group.";
return RedirectToAction(nameof(StepTwo), new { uid });
}

var fileTypeId = await fileRepository.GetFileTypeIdAsync(fileTypeDescription, cancellationToken);
if (!fileTypeId.HasValue)
{
TempData["OnboardingMessage"] = "File type could not be resolved.";
return RedirectToAction(nameof(StepTwo), new { uid });
}

var fileName = Path.GetFileName(file.FileName);
await using var fileStream = file.OpenReadStream();

try
{
await clientFileStorage.UploadAsync(uid, fileName, fileStream, file.ContentType, cancellationToken);
}
catch (InvalidOperationException exception)
{
TempData["OnboardingMessage"] = exception.Message;
return RedirectToAction(nameof(StepTwo), new { uid });
}
catch (RequestFailedException exception)
{
logger.LogError(exception,
"Azure Blob upload failed for client {ClientUid} and file {FileName}.",
uid,
fileName);

TempData["OnboardingMessage"] = "The file could not be uploaded to storage.";
return RedirectToAction(nameof(StepTwo), new { uid });
}

await fileRepository.InsertAsync(uid, fileTypeId.Value, fileName, cancellationToken);

TempData["OnboardingMessage"] = $"{fileName} uploaded.";
return RedirectToAction(nameof(StepTwo), new { uid });
}

This ordering matters. If the blob upload fails, you do not want a database row claiming the file exists.

Docs: Upload files in ASP.NET CoreLogging in .NET and ASP.NET Core

5. Decide how you want to authenticate

I ended up with two useful modes.

Scenario Config Auth path
Local development with a storage key ConnectionString set BlobContainerClient(connectionString, containerName)
Shared Azure environment ConnectionString empty DefaultAzureCredential

For local development, a connection string is the fastest way to prove the upload path works.

For Azure-hosted environments, I would rather use managed identity or a service principal with the right blob permissions than ship account keys around.

Docs: Authorize access to blobs with Microsoft Entra IDAssign Azure roles for blob data

6. Verify the result

Once the upload succeeds, you should see a blob inside the container with this shape:

1
2
3
chatbot-files/
<client-uid>/
<file-name>

A quick test flow is:

  1. Open the page that posts the file.
  2. Upload a file for a known client uid.
  3. Check the Azure portal or Storage Explorer.
  4. Confirm both the blob and the database row exist.

That was enough in my case to move from “the UI says uploaded” to “the file is actually in storage”.

Docs: Azure Storage Explorer