Accepting an upload and deciding who may download it later are two halves of the same feature. Checking an extension and storing the file in a public directory can discard the authorization established by the upload screen. I define the accepted content and access contract together when designing a file-sharing workflow.
Separate the filename from the content
The submitted filename is a descriptive label, not a trustworthy storage path or content type. The .png extension and image/png header are supplied by the client as well. This independent .NET 10 console sample therefore checks a PNG signature and generates an application-owned storage name.
static bool LooksLikePng(byte[] bytes) => bytes.Length >= 8 &&
bytes.AsSpan(0,8).SequenceEqual(new byte[]{137,80,78,71,13,10,26,10});
var header = new byte[]{137,80,78,71,13,10,26,10};
var storedName = Guid.NewGuid().ToString("N") + ".png";
var ownerId = 7; var callerId = 9;
Console.WriteLine(LooksLikePng(header));
Console.WriteLine(ownerId == callerId ? storedName : "forbidden");
The outputs are True and forbidden. The input deliberately contains only the eight signature bytes. Matching them does not prove that the file is a complete, decodable PNG. LooksLikePng is a preliminary filter, not a secure upload implementation. An image workflow also needs structural validation and limits on decoding and dimensions.
Apply limits while accepting the request
Choose an allowlist of permitted types and enforce size limits on the server, not just in the browser. The application should keep its own bound while reading the stream as well. A small compressed file can expand into a very large pixel allocation, so an upload-byte limit is not the only relevant resource boundary.
The application generates the stored name and retains the original name as metadata. Resolve the final path under a permitted storage root instead of accepting submitted directories or absolute paths. Two files sharing a name and the same content being uploaded twice are separate concerns: a GUID reduces naming collisions but does not establish an idempotent business operation.
Authorize the download as a resource operation
The ownerId == callerId comparison demonstrates only an access decision. A real endpoint must derive the caller from its authenticated session and the owner from trusted metadata, rather than accepting either as a form claim. Sharing or role rules belong at the same boundary. Knowing a file identifier must not be sufficient to read it.
Putting a private document in a public static directory and treating its URL as secret bypasses this check. An authorized download endpoint can open the file and return a controlled content type and disposition. Use the framework's safe file-response handling for submitted display names rather than concatenating untrusted text into response headers.
Storage and metadata can fail independently
The file write can succeed while metadata persistence fails, or vice versa. A filesystem write does not automatically participate in a SQL transaction. Pick an explicit lifecycle: temporary storage and a completed state, compensating cleanup after failed persistence, or a controlled orphan cleanup process. That cleanup must not delete a file still referenced by a valid record.
Cancellation should not leave a partial upload marked as ready. Upload progress is not proof of durable completion; uploading, processing and ready are different states. The UI can communicate those states accurately without exposing implementation details to the user.
Test the access boundary as well as the parser
Exercise a valid file, a forged extension, a signature-only file, oversized input and an interrupted request. Test downloads as the owner, an authorized shared user and an unrelated caller. Include the URL of a file whose metadata has been deleted. Small controlled test files are sufficient; real personal documents are unnecessary for these checks.
ASP.NET Core file upload guidance; Resource-based authorization.