Azure Blob Storage — Store and Serve Files at Scale
Azure.Storage.Blobs setup with DefaultAzureCredential, upload streams, list/download/delete blobs, SAS tokens for time-limited access, metadata, virtual directories, and access tier cost optimization.
Setup
dotnet add package Azure.Storage.Blobs
dotnet add package Azure.Identity # for DefaultAzureCredentialBlobServiceClient — Connection String or DefaultAzureCredential
// appsettings.json
{
"Azure": {
"StorageAccountName": "mystorageacct",
"BlobContainerName": "uploads"
},
"ConnectionStrings": {
"AzureStorage": "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net"
}
}// 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")));// 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
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
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
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);
}// 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
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.
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);
}// 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
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// 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 |
// 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.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.