Azure Blob Storage — Storing and Retrieving Files in .NET
Use Azure Blob Storage in .NET: uploading patient documents, generating SAS tokens for secure access, streaming large files, lifecycle management policies, and Managed Identity authentication.
When to Use Blob Storage
Use Azure Blob Storage for:
→ Patient documents (discharge summaries, consent forms, referral letters)
→ Clinical images (X-rays, scans — DICOM or JPEG)
→ Prescription PDFs
→ Audit export files (large MHRA reports)
→ Application logs and diagnostic files
Do NOT store in Blob Storage:
→ Structured query data (use SQL)
→ Session state (use Redis)
→ Small reference data (use SQL or distributed cache)
Blob Storage pricing: pay only for what you store (~£0.018/GB/month for LRS)
Much cheaper than SQL storage for binary filesSetup and Authentication
// NuGet: Azure.Storage.Blobs, Azure.Identity
// Register BlobServiceClient with Managed Identity (no credentials)
builder.Services.AddSingleton(sp =>
{
var uri = new Uri(builder.Configuration["AzureStorage:BlobEndpoint"]!);
return new BlobServiceClient(uri, new DefaultAzureCredential());
});
// BlobEndpoint in appsettings.json:
// "AzureStorage": { "BlobEndpoint": "https://clinicalstorage.blob.core.windows.net" }
// For local development with Azurite (storage emulator):
// "AzureStorage": { "BlobEndpoint": "http://127.0.0.1:10000/devstoreaccount1" }
// Add "UseDevelopmentStorage=true" as the connection string for Azurite
// DI registration as a typed service:
builder.Services.AddScoped<IDocumentStorageService, AzureBlobDocumentStorageService>();Uploading a Patient Document
public interface IDocumentStorageService
{
Task<string> UploadAsync(
Guid patientId, string fileName, Stream content, string contentType,
CancellationToken ct = default);
}
public sealed class AzureBlobDocumentStorageService : IDocumentStorageService
{
private const string ContainerName = "patient-documents";
private readonly BlobServiceClient _blobService;
public AzureBlobDocumentStorageService(BlobServiceClient blobService) =>
_blobService = blobService;
public async Task<string> UploadAsync(
Guid patientId, string fileName, Stream content, string contentType,
CancellationToken ct = default)
{
var container = _blobService.GetBlobContainerClient(ContainerName);
await container.CreateIfNotExistsAsync(
PublicAccessType.None, // no public access — use SAS tokens
cancellationToken: ct);
// Blob name: patient-id/timestamp-filename (prevents collisions)
var blobName = $"{patientId:N}/{DateTime.UtcNow:yyyyMMddHHmmss}-{fileName}";
var blob = container.GetBlobClient(blobName);
await blob.UploadAsync(content,
new BlobHttpHeaders { ContentType = contentType },
cancellationToken: ct);
return blobName; // store this reference in the database, not the full URL
}
}Downloading and Streaming
public async Task<Stream> DownloadAsync(string blobName, CancellationToken ct = default)
{
var blob = _blobService
.GetBlobContainerClient(ContainerName)
.GetBlobClient(blobName);
var response = await blob.DownloadStreamingAsync(cancellationToken: ct);
return response.Value.Content;
}
// ASP.NET Core endpoint — stream directly to client (no buffering in memory)
app.MapGet("/api/patients/{patientId}/documents/{documentId}", async (
Guid patientId, Guid documentId,
IDocumentStorageService storage,
IDocumentRepository documentRepo,
CancellationToken ct) =>
{
var document = await documentRepo.GetByIdAsync(documentId, ct);
if (document is null || document.PatientId != patientId)
return Results.NotFound();
var stream = await storage.DownloadAsync(document.BlobName, ct);
return Results.Stream(stream, document.ContentType, document.FileName);
});SAS Tokens for Secure Direct Access
// SAS (Shared Access Signature): time-limited URL for direct blob access
// Use when: client needs to read/upload directly without routing through your API
public string GenerateReadSasUrl(string blobName, TimeSpan expiry)
{
var blob = _blobService
.GetBlobContainerClient(ContainerName)
.GetBlobClient(blobName);
var sasBuilder = new BlobSasBuilder
{
BlobContainerName = ContainerName,
BlobName = blobName,
Resource = "b", // "b" = blob
ExpiresOn = DateTimeOffset.UtcNow.Add(expiry)
};
sasBuilder.SetPermissions(BlobSasPermissions.Read);
// Using account key or user delegation key (preferred with Managed Identity)
var sasUri = blob.GenerateSasUri(sasBuilder);
return sasUri.ToString();
}
// Usage: generate a 10-minute download link for a patient's discharge summary
var sasUrl = _storage.GenerateReadSasUrl(document.BlobName, TimeSpan.FromMinutes(10));
// Return sasUrl to the client — they download directly from Azure without hitting your API
// Link expires after 10 minutes — clinical documents not publicly accessibleLifecycle Management
// Azure Storage lifecycle policy: archive old documents to reduce cost
// Set via Azure Portal or Bicep
{
"rules": [
{
"name": "archive-old-documents",
"enabled": true,
"type": "Lifecycle",
"definition": {
"filters": { "blobTypes": ["blockBlob"], "prefixMatch": ["patient-documents/"] },
"actions": {
"baseBlob": {
"tierToCool": { "daysAfterModificationGreaterThan": 90 },
"tierToArchive": { "daysAfterModificationGreaterThan": 365 }
}
}
}
}
]
}Storage tiers for patient documents:
Hot (0-90 days): Frequently accessed — standard read cost
Cool (90-365 days): Infrequently accessed — lower storage, higher read cost
Archive (365+ days): Rarely accessed — cheapest storage, hours to rehydrate
Clinical consideration: MHRA requires retention of clinical records for up to 8 years.
→ Documents are moved to Archive tier after 1 year
→ Archive retrieval takes up to 15 hours — acceptable for regulatory audit requests
→ Never delete clinical documents (immutability policy)Production issue I've seen: A clinical system stored patient discharge PDF paths as full Azure Blob Storage URLs including the storage account key in the query string — a SAS URL with no expiry. These URLs were stored in the database, included in system emails, and logged by middleware. When the storage account key was rotated (a routine security operation), all stored SAS URLs became invalid simultaneously. 4,000 stored links to clinical documents stopped working. The fix required regenerating SAS URLs for all stored documents and replacing the storage scheme with blob name references. Storing the blob name (not the URL) in the database and generating time-limited SAS URLs on demand is the correct pattern.
Key Takeaway
Store blob names (not URLs) in your database — generate time-limited SAS URLs on demand for secure direct access. Use
PublicAccessType.Noneon all containers — never make patient documents publicly accessible. Authenticate with Managed Identity (DefaultAzureCredential) — no storage account keys in configuration. Stream large files directly to clients usingResults.Streamto avoid buffering in memory. Apply lifecycle policies to automatically tier old documents from Hot to Cool to Archive, reducing storage costs while satisfying clinical record retention requirements.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.