Learnixo

Azure for Developers · Lesson 4 of 6

Azure Blob Storage — Files, Images, and Documents

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 files

Setup and Authentication

C#
// 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

C#
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

C#
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

C#
// 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 accessible

Lifecycle Management

JSON
// 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.None on 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 using Results.Stream to 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.