A File Storage Abstraction: Local Disk, Azure Blob Storage and S3; Direct Uploads with Presigned/SAS URLs
Keep files behind one IFileStore: validate the path twice on the local disk, hand out SAS or presigned URLs that live a few minutes for downloads and uploads on Azure Blob and S3, and check an uploaded file only in the "complete" step, because storage looks at neither size nor content. Bookstore's invoices and covers were tested with Azurite and RustFS; metadata stays in the database.
Version note: The code in this post was built and run with .NET 10, Azure.Storage.Blobs 12.30.1, AWSSDK.S3 4.1.0, Azurite 3.37.0 and RustFS 1.0.5.
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, has kept invoices and book covers on the local disk so far. In this post we run the same IFileStore on Azure Blob Storage and S3. Three questions:
First: the local implementation builds the path with Path.GetFullPath(Path.Combine(root, path)) and checks whether the result starts with root. Does covers/../../files-backup/db.bak pass that check?
Second: for a cover upload you gave the browser a 10-minute SAS URL. The cover limit is 10 MB. Can the browser upload 12 MB to that URL?
Third: the S3 client's ServiceURL is http://127.0.0.1:52582. Which scheme does the presigned URL generated by the SDK start with?
The answers come from real runs. The first passes: if the root is .../files, then .../files-backup starts with the same letters. In the second the storage returns 201; a SAS does not limit size. In the third the URL starts with https:// and the request fails in the TLS handshake.
The Final Shape of IFileStore
In the fifth post we produced the invoice once and stored it; every download still went through the API. For large files and high traffic that means the API carries the bytes. We added a short-lived URL the client uses directly:
// Paths are relative, '/'-separated keys such as "invoices/2026/BKS-2026-001042.pdf", on every backend
public interface IFileStore
{
Task<StoredFile> SaveAsync(string path, Stream content, string contentType, CancellationToken cancellationToken = default);
// FileNotFoundException if the path does not exist
Task<Stream> OpenReadAsync(string path, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string path, CancellationToken cancellationToken = default);
Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default);
// A short-lived URL the client downloads from directly; the API is not in the data path
Task<Uri> GetDownloadUrlAsync(string path, TimeSpan lifetime, string? downloadFileName = null, CancellationToken cancellationToken = default);
}
// Object stores can hand out a URL the browser uploads to; the local disk cannot
public interface IDirectUploadStore
{
Task<DirectUpload> CreateUploadAsync(string path, string contentType, TimeSpan lifetime, CancellationToken cancellationToken = default);
}
Direct upload is a separate interface, because the local disk cannot do it. Instead of forcing every implementation to implement a method it cannot support, we ask DI for the capability. Configuration picks the implementation: Notifications:FileStore:Provider is Local, AzureBlob or S3.
The Local Disk and Path Traversal
Path traversal is a value from outside escaping the allowed folder in the file system through ../. In Bookstore our own code builds the paths, but values from outside, such as an ISBN or an order number, go into them. We set up two lines of defense. The first rejects the path by its shape before it touches the file system:
// Rejects anything that is not a plain relative key before it reaches a file system or a URL
public static string Validate(string path)
{
if (string.IsNullOrWhiteSpace(path) || path.Length > 512 || path.StartsWith('/') || path.Contains('\\') || path.Contains(':')
|| path.Any(char.IsControl) || path.Split('/').Any(segment => segment is "" or "." or ".."))
{
throw new ArgumentException($"'{path}' is not a valid storage path.", nameof(path));
}
return path;
}
The second checks that the combined full path stays under the root, with a root that ends in a separator:
private readonly string _root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(options.Value.RootPath)) + Path.DirectorySeparatorChar;
private string Resolve(string path)
{
StoragePath.Validate(path);
// Second line of defense: whatever the string looked like, the final path must stay under the root
string fullPath = Path.GetFullPath(Path.Combine(_root, path));
if (!fullPath.StartsWith(_root, StringComparison.Ordinal))
{
throw new ArgumentException($"Path '{path}' is outside the file store root.", nameof(path));
}
return fullPath;
}
We compared it with a StartsWith(root) check without the trailing separator:
$ dotnet run -- traversal
invoices/2026/BKS-2026-001042.pdf naive check: passes LocalFileStore: accepted
../appsettings.json naive check: blocked LocalFileStore: rejected
covers/../../files-backup/db.bak naive check: passes LocalFileStore: rejected
/etc/passwd naive check: blocked LocalFileStore: rejected
invoices/./2026//x.pdf naive check: passes LocalFileStore: rejected
The third line is the common bug: the check passes, but the file is in the folder next to the root. The /etc/passwd line is instructive too: Path.Combine ignores the first argument entirely when the second is a rooted path. On Windows \ is a separator as well, so Validate rejects the backslash on every operating system; that way the same path cannot be a file name on Linux and a folder change on Windows. The tests also cover C:/Windows/win.ini and covers\..\..\web.config.
The local disk cannot produce signed URLs; the API does that. GetDownloadUrlAsync signs the path, the expiry time and the file name with HMACSHA256, a message authentication code computed with a secret key, and the /files/... endpoint verifies the signature with a constant-time comparison:
$ curl -s http://127.0.0.1:52560/api/orders/1042/invoice/link
{"url":"http://127.0.0.1:52560/files/invoices/2026/BKS-2026-001042.pdf?expires=1794726330&name=invoice-BKS-2026-001042.pdf&sig=<redacted>","expiresAt":"2026-11-15T07:05:30.446474+00:00"}
$ curl -s -o /dev/null -w 'GET -> %{http_code} %{size_download} bytes\n' "$URL"
GET -> 200 39870 bytes
$ curl -s -o /dev/null -w 'GET tampered -> %{http_code}\n' "${URL/001042/001043}"
GET tampered -> 403
We replaced the signatures in the outputs with <redacted>; while they are valid they are like a password and must not be logged.
Azure Blob Storage and Azurite
Azurite is Microsoft's local emulator for Blob, Queue and Table. With the default account, docker run -p 10000:10000 mcr.microsoft.com/azure-storage/azurite azurite-blob --blobHost 0.0.0.0 is enough and the connection string is UseDevelopmentStorage=true; that shortcut uses Azurite's documented development key. Because we used another port and did not want to write the well-known key anywhere, we started Azurite with AZURITE_ACCOUNTS, an account named bookstoredev and a key we generated, and passed the connection string as an environment variable. SDK 12.30 sends its requests with API version 2026-10-06; the Azurite 3.37.0 we used does not know that version, answered the first request with The API version 2026-10-06 is not supported by Azurite, and needed the --skipApiVersionCheck flag.
Saving and reading map directly to the SDK. The real work is producing the SAS:
private BlobSasBuilder NewSas(string path, TimeSpan lifetime, BlobSasPermissions permissions)
{
DateTimeOffset now = time.GetUtcNow();
BlobSasBuilder sas = new(permissions, now + lifetime)
{
BlobContainerName = container.Name,
BlobName = StoragePath.Validate(path),
Resource = "b",
// A few minutes of tolerance for clocks that run behind
StartsOn = now.AddMinutes(-5),
};
return sas;
}
private async Task<Uri> SignAsync(string path, BlobSasBuilder sas, CancellationToken cancellationToken)
{
BlobClient blob = container.GetBlobClient(path);
if (blob.CanGenerateSasUri)
{
// Client built with the account key (Azurite, or a key from Key Vault)
return blob.GenerateSasUri(sas);
}
// Managed identity / Entra ID: a user delegation SAS signed with a key the service hands out
BlobServiceClient service = container.GetParentBlobServiceClient();
UserDelegationKey key = await service.GetUserDelegationKeyAsync(sas.StartsOn, sas.ExpiresOn, cancellationToken);
BlobUriBuilder builder = new(blob.Uri) { Sas = sas.ToSasQueryParameters(key, service.AccountName) };
return builder.ToUri();
}
The download URL carries read permission only (sp=r) and tells the browser the file name through ContentDisposition. If production uses a managed identity instead of the account key, the second branch runs: the SAS is signed not by the account key but by a user delegation key that Entra ID hands out, valid for at most 7 days. A full round with StorageLab against Azurite:
$ dotnet run -- roundtrip invoice.pdf
store: AzureBlobFileStore
saved invoices/2026/BKS-2026-001042.pdf (39,870 bytes) in 22 ms, exists=True
url: http://127.0.0.1:52581/bookstoredev/bookstore/invoices/2026/BKS-2026-001042.pdf?sv=2026-10-06&st=2026-11-15T06%3A54%3A56Z&se=2026-11-15T07%3A04%3A56Z&sr=b&sp=r&rscd=attachment%3B+filename%3Dinvoice-BKS-2026-001042.pdf&sig=<redacted>
GET url -> 200, 39,870 bytes, same=True, attachment; filename=invoice-BKS-2026-001042.pdf
GET other invoice with the same signature -> 403
GET after expiry -> 403
delete -> True, delete again -> False
Changing one character of the path invalidates the signature, and the expired URL returns 403. The container is private; the same address without a SAS returned 403 as well. The concepts of storage accounts, tiers and SAS types are in the Blob Storage post of the Cloud Fundamentals series. This section is based on the official documentation.
S3 and S3-Compatible Servers
For trying the S3 side locally our first choice was MinIO, but the minio/minio image is no longer on Docker Hub (we checked on 15 November 2026). We used RustFS, which speaks the same API and is Apache 2.0 licensed, with the rustfs/rustfs:1.0.5 image. We give the AWS SDK ServiceURL and ForcePathStyle = true; the SDK's default chain reads the access key from environment variables. The first run ended like this:
url: https://127.0.0.1:52582/bookstore/invoices/2026/BKS-2026-001042.pdf?X-Amz-Expires=301&...&X-Amz-Signature=<redacted>
Unhandled exception. System.Net.Http.HttpRequestException: The SSL connection could not be established, see inner exception.
---> System.Security.Authentication.AuthenticationException: Cannot determine the frame size or a corrupted frame was received.
Saving and reading worked over http, but the presigned URL was generated with https. GetPreSignedUrlRequest.Protocol defaults to HTTPS and is not derived from ServiceURL:
// Presigned URLs are https unless told otherwise, even when ServiceUrl is http
private Protocol UrlProtocol => options.Value.ServiceUrl?.StartsWith("http://", StringComparison.OrdinalIgnoreCase) == true ? Protocol.HTTP : Protocol.HTTPS;
public async Task<Uri> GetDownloadUrlAsync(string path, TimeSpan lifetime, string? downloadFileName = null, CancellationToken cancellationToken = default)
{
GetPreSignedUrlRequest request = new()
{
BucketName = Bucket,
Key = StoragePath.Validate(path),
Verb = HttpVerb.GET,
Expires = time.GetUtcNow().Add(lifetime).UtcDateTime,
Protocol = UrlProtocol,
};
request.ResponseHeaderOverrides.ContentDisposition = StoragePath.ContentDisposition(downloadFileName ?? Path.GetFileName(path));
return new Uri(await s3.GetPreSignedURLAsync(request));
}
After the fix the same round passed on S3 too: the download is 200 with identical bytes, another invoice and the expired URL get 403, the second delete returns False. S3's DeleteObject returns 204 whether or not the object existed, so DeleteAsync checks existence first. On AWS you sign the URL with the application's IAM role rather than an access key; a presigned URL signed with SigV4 is valid for at most 7 days.
Uploading Directly from the Browser
The cover upload has two steps. First the API returns a 10-minute URL that allows writing to a single key under uploads/ only. The browser puts the file straight into storage. Then it tells the API it is done, and only then does the API look at the uploaded file for the first time. This is the upload counterpart of the asynchronous flow from the first post. On Azure we picked Create as the write permission:
public async Task<DirectUpload> CreateUploadAsync(string path, string contentType, TimeSpan lifetime, CancellationToken cancellationToken = default)
{
// Create only: the URL cannot read, list or overwrite an existing blob
BlobSasBuilder sas = NewSas(path, lifetime, BlobSasPermissions.Create);
Uri url = await SignAsync(path, sas, cancellationToken);
return new DirectUpload(url, "PUT", new Dictionary<string, string> { ["x-ms-blob-type"] = "BlockBlob", ["Content-Type"] = contentType }, sas.ExpiresOn);
}
The same experiment on both backends; the Notifications__FileStore__Provider environment variable picks the backend:
$ dotnet run -- upload cover-dune.jpg
store: AzureBlobFileStore
PUT image -> 201 Created
PUT again with the same URL -> 403 This request is not authorized to perform this operation using this permission.
GET with the upload URL -> 403
PUT 12 MB of zeros -> 201 Created
$ dotnet run -- upload cover-dune.jpg
store: S3FileStore
PUT image -> 200 OK
PUT again with the same URL -> 200 OK
GET with the upload URL -> 403
PUT 12 MB of zeros -> 200 OK
There are two important results. First, both stores accepted 12 MB: neither a SAS nor a plain presigned PUT has a size or content limit. Second, the presigned PUT on S3 can be reused until it expires. That is why every upload goes to a new key with an unguessable uploadId and the URL lives briefly. We do the checks in the "complete" step, with CoverImageProcessor from the seventh post:
$ curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:52560/api/books/9780441172719/cover/uploads/01a1de022a8070459c6d00b32e55516b/complete
{"format":"Jpeg","width":2400,"height":1600,"length":112704}
HTTP 202
$ curl -s -w '\nHTTP %{http_code}\n' -X POST http://127.0.0.1:52560/api/books/9780441172719/cover/uploads/01a1de022b687eec8b7b6b5768d0ef3b/complete
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"detail":"The upload is larger than 10,485,760 bytes."}
HTTP 415
A rejected file is deleted from the staging area; an accepted one moves to covers/{isbn}/original.jpg and the thumbnail job enters the queue. For the browser to PUT to another origin, the storage account needs a CORS rule. In development we set it up at startup for localhost:4200, and Azurite's preflight answer returned Access-Control-Allow-Origin: http://localhost:4200 and Access-Control-Allow-Headers: content-type,x-ms-blob-type. The bookstore-web side could look like this:
// Cover upload in bookstore-web
uploadCover(isbn: string, file: File) {
return this.http.post<CoverUpload>(`/api/books/${isbn}/cover/uploads`, null, { params: { contentType: file.type } }).pipe(
switchMap(upload => this.http.put(upload.url, file, { headers: upload.headers }).pipe(map(() => upload.uploadId))),
switchMap(uploadId => this.http.post(`/api/books/${isbn}/cover/uploads/${uploadId}/complete`, null)));
}
If you have an HTTP interceptor that adds a token for the API, exclude the storage address; otherwise you send the user's token to another host.
Metadata in the Database, Bytes in Storage
Storage does not know who owns a file, which order it was produced for or when it should be deleted. That information lives in the database:
// The bytes live in the file store; what we know about them lives in the database
public sealed class StoredFileRecord
{
public required string Path { get; init; }
public required string Purpose { get; init; }
public required string ContentType { get; init; }
public long Length { get; init; }
public required string Sha256 { get; init; }
// Who may get a download URL, e.g. the customer of the order
public string? OwnerId { get; init; }
public DateTime CreatedAtUtc { get; init; }
}
$ sqlite3 bookstore.db 'select Path, Purpose, Length, substr(Sha256, 1, 16), OwnerId from StoredFiles'
covers/9780441172719/original.jpg|cover-original|112704|9391743a5d647222|
invoices/2026/BKS-2026-001042.pdf|invoice|39870|7cc12affe7311e53|ada@example.com
The ownership check happens once, in the link endpoint; the storage URL itself carries no identity, and whoever holds it can download until it expires. That is why download URLs live 5 minutes. The Sha256 value helps to detect that the same file was uploaded twice and to check integrity. Deletion has two steps: first mark the record, then a job deletes the object in storage. In the opposite order, if the database transaction rolls back you are left with a record but no file. Lifecycle rules (such as deleting old staging objects after a day) can be defined on the storage side. Scanning user files with an antivirus belongs to this step too; an engine such as ClamAV or the storage provider's malware scanning can be added to the "complete" step.
Keeping files in the database as varbinary is reasonable for a small number of small files. But the database backup grows, every download holds a database connection, and there is no direct access path like a SAS. Measure the number of files and their average size before you decide.
In Production
Keep the container private. A public container exposes all invoices at predictable paths (invoices/2026/...) to everyone.
Keep SAS and presigned URL lifetimes short and do not log them. Minutes are enough for downloads, 10 to 15 minutes for uploads.
In production use managed identities and IAM roles instead of account keys or long-lived access keys; if a key is unavoidable, it should come from a secret store as in the Configuration and Secret Management series.
Watch the dependency tree. Azure.Storage.Blobs 12.30.1 brought in Apache.Arrow 22.1.0 transitively and the license check broke: the license came as a file and the tool could not determine its type. We read the file, saw it was Apache 2.0 and added a version-pinned override.
Clock skew: moving StartsOn a few minutes back avoids "not yet valid" errors on clients whose clocks run behind.
Common Pitfalls
A public container with "nobody can guess the address anyway". Invoice numbers are sequential.
Long-lived SAS. A 30-day link in an email means a file open to anyone for 30 days once the email is forwarded.
Skipping the checks on direct uploads. Storage looks at neither size nor content; if the check does not happen in the "complete" step, it does not happen at all.
Writing your own root check without the trailing separator. Like the files-backup example.
Deciding to keep files in the database without measuring their size.
A Small Experiment
★☆☆ Add invoices/2026/%2e%2e/x.pdf to the StorageLab traversal command. What does LocalFileStore say? If this value came from a URL, at which stage would ASP.NET Core routing decode it?
★★☆ Add BlobSasPermissions.Write to the Azure direct upload URL and try the second PUT with the same URL again. Why does the result change?
★★★ Write a job that deletes staging objects older than 24 hours, and make StoredFileRecord deletion two-phase: fill DeletedAtUtc first, then delete from storage. What happens if the job is interrupted halfway?
We finish the series in the next post: with Hangfire we build the job chain that produces the invoice when an order is placed, writes it to storage and sends the confirmation email with a link, attach the monthly Excel report to a recurring job, and verify everything in an integration test with Mailpit and Azurite.