.NET & C# Development · Lesson 79 of 92

Upload to Azure Blob — Generate SAS URLs on Demand

Setup

dotnet add package Azure.Storage.Blobs
dotnet add package Azure.Identity   # for DefaultAzureCredential

BlobServiceClient — Connection String or DefaultAzureCredential

C#
// appsettings.json
{
  "Azure": {
    "StorageAccountName": "mystorageacct",
    "BlobContainerName": "uploads"
  },
  "ConnectionStrings": {
    "AzureStorage": "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net"
  }
}
C#
// Program.cs — prefer DefaultAzureCredential in production (no secrets in config)
builder.Services.AddSingleton(sp =>
{
    var cfg = sp.GetRequiredService<IConfiguration>();
    var accountName = cfg["Azure:StorageAccountName"]!;
    var uri = new Uri($"https://{accountName}.blob.core.windows.net");

    // Works with: managed identity, workload identity, Azure CLI, env vars
    return new BlobServiceClient(uri, new DefaultAzureCredential());
});

// Or with connection string (dev/test)
builder.Services.AddSingleton(_ =>
    new BlobServiceClient(builder.Configuration.GetConnectionString("AzureStorage")));
C#
// Register the container client as a convenience
builder.Services.AddSingleton(sp =>
{
    var service = sp.GetRequiredService<BlobServiceClient>();
    var cfg = sp.GetRequiredService<IConfiguration>();
    var container = service.GetBlobContainerClient(cfg["Azure:BlobContainerName"]!);
    container.CreateIfNotExists(PublicAccessType.None);
    return container;
});

Uploading

C#
public class BlobStorageService
{
    private readonly BlobContainerClient _container;
    public BlobStorageService(BlobContainerClient container) => _container = container;

    public async Task<string> UploadAsync(
        Stream content, string fileName, string contentType,
        CancellationToken ct = default)
    {
        // Virtual directory: "images/2026/04/guid-filename.jpg"
        var blobName = $"{DateTime.UtcNow:yyyy/MM}/{Guid.NewGuid()}-{fileName}";
        var blob = _container.GetBlobClient(blobName);

        await blob.UploadAsync(content, new BlobUploadOptions
        {
            HttpHeaders = new BlobHttpHeaders
            {
                ContentType = contentType,
                CacheControl = "public, max-age=31536000"   // 1 year for immutable files
            },
            Metadata = new Dictionary<string, string>
            {
                ["uploadedBy"] = "api",
                ["originalName"] = fileName
            },
            // Overwrite = false throws if blob already exists (safe default)
        }, ct);

        return blobName;
    }

    // Upload from local file path
    public async Task<string> UploadFileAsync(string filePath, string contentType)
    {
        await using var stream = File.OpenRead(filePath);
        return await UploadAsync(stream, Path.GetFileName(filePath), contentType);
    }
}

Listing Blobs

C#
public async IAsyncEnumerable<BlobItem> ListAsync(
    string? prefix = null,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var item in _container.GetBlobsAsync(prefix: prefix, cancellationToken: ct))
        yield return item;
}

// Usage — list all images uploaded in April 2026
await foreach (var blob in storage.ListAsync("2026/04/"))
{
    Console.WriteLine($"{blob.Name} — {blob.Properties.ContentLength} bytes");
}

Downloading

C#
public async Task<(Stream Content, string ContentType)> DownloadAsync(
    string blobName, CancellationToken ct = default)
{
    var blob = _container.GetBlobClient(blobName);
    var response = await blob.DownloadStreamingAsync(cancellationToken: ct);
    return (response.Value.Content, response.Value.Details.ContentType);
}
C#
// Controller endpoint
[HttpGet("files/{*blobName}")]
public async Task<IActionResult> Download(string blobName)
{
    try
    {
        var (stream, contentType) = await _storage.DownloadAsync(blobName);
        var fileName = Path.GetFileName(blobName);
        return File(stream, contentType, fileDownloadName: fileName);
    }
    catch (RequestFailedException ex) when (ex.Status == 404)
    {
        return NotFound();
    }
}

Deleting

C#
public async Task<bool> DeleteAsync(string blobName, CancellationToken ct = default)
{
    var blob = _container.GetBlobClient(blobName);
    var response = await blob.DeleteIfExistsAsync(
        snapshotsOption: DeleteSnapshotsOption.IncludeSnapshots,
        cancellationToken: ct);
    return response.Value;
}

SAS Tokens for Time-Limited Access

Generate a URL that expires — no proxy needed, the client downloads directly from Azure.

C#
public Uri GenerateSasUrl(string blobName, TimeSpan duration, BlobSasPermissions permissions)
{
    var blob = _container.GetBlobClient(blobName);

    // Requires StorageSharedKeyCredential (not DefaultAzureCredential)
    // Use User Delegation SAS with DefaultAzureCredential in production
    var sasBuilder = new BlobSasBuilder
    {
        BlobContainerName = _container.Name,
        BlobName = blobName,
        Resource = "b",
        ExpiresOn = DateTimeOffset.UtcNow.Add(duration)
    };
    sasBuilder.SetPermissions(permissions);

    return blob.GenerateSasUri(sasBuilder);
}

// User Delegation SAS (works with DefaultAzureCredential — no account key needed)
public async Task<Uri> GenerateDelegationSasUrlAsync(string blobName, TimeSpan duration)
{
    var delegationKey = await _container.GetParentBlobServiceClient()
        .GetUserDelegationKeyAsync(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.Add(duration));

    var sasBuilder = new BlobSasBuilder
    {
        BlobContainerName = _container.Name,
        BlobName = blobName,
        Resource = "b",
        ExpiresOn = DateTimeOffset.UtcNow.Add(duration)
    };
    sasBuilder.SetPermissions(BlobSasPermissions.Read);

    var blob = _container.GetBlobClient(blobName);
    return blob.GenerateSasUri(sasBuilder);
}
C#
// API endpoint: return a 15-minute download URL
[HttpGet("files/{*blobName}/sas")]
public async Task<IActionResult> GetSasUrl(string blobName)
{
    var url = await _storage.GenerateDelegationSasUrlAsync(blobName, TimeSpan.FromMinutes(15));
    return Ok(new { url });
}

Setting Content Type and Metadata After Upload

C#
public async Task UpdateMetadataAsync(string blobName, Dictionary<string, string> metadata)
{
    var blob = _container.GetBlobClient(blobName);
    await blob.SetMetadataAsync(metadata);
}

public async Task UpdateContentTypeAsync(string blobName, string contentType)
{
    var blob = _container.GetBlobClient(blobName);
    var props = await blob.GetPropertiesAsync();
    var headers = new BlobHttpHeaders
    {
        ContentType = contentType,
        CacheControl = props.Value.CacheControl
    };
    await blob.SetHttpHeadersAsync(headers);
}

Virtual Directories

Blobs are flat — there are no real folders. A / in the name creates a virtual directory:

receipts/2026/04/15/order-123.pdf
avatars/users/guid.jpg
temp/uploads/unprocessed/abc.csv
C#
// List only blobs under a prefix (simulates folder listing)
await foreach (var item in _container.GetBlobsByHierarchyAsync(
    delimiter: "/", prefix: "receipts/2026/04/"))
{
    if (item.IsBlob)
        Console.WriteLine($"File: {item.Blob.Name}");
    else
        Console.WriteLine($"Virtual dir: {item.Prefix}");
}

Access Tiers — Cost Optimization

| Tier | Use case | Storage cost | Access cost | |---------|---------------------------|-------------|-------------| | Hot | Frequently accessed files | High | Low | | Cool | Infrequent (30+ days) | Medium | Medium | | Archive | Long-term (180+ days) | Very low | High + rehydrate delay |

C#
// Move a blob to Cool tier after 30 days of no access
public async Task SetAccessTierAsync(string blobName, AccessTier tier)
{
    var blob = _container.GetBlobClient(blobName);
    await blob.SetAccessTierAsync(tier);
}

// Example: archive old receipts
var cutoff = DateTime.UtcNow.AddDays(-180);
await foreach (var blob in _container.GetBlobsAsync(prefix: "receipts/"))
{
    if (blob.Properties.LastModified < cutoff)
    {
        var client = _container.GetBlobClient(blob.Name);
        await client.SetAccessTierAsync(AccessTier.Archive);
    }
}

Use Lifecycle Management policies in the Azure portal to automate tier transitions — more reliable than doing it in code.