← All articles

Supabase Storage: secure image uploads through a .NET API

Build the upload path with bucket access rules, server keys, byte validation, a reusable HTTP adapter, CSRF, legacy URLs and complete backups.

t.Turgay
Supabase Storage: secure image uploads through a .NET API

Saving an article title and uploading its cover have different lifecycles. A file has a size, format, name, access URL and retention policy. If an API redeploy loses the image, a healthy database still leaves the blog incomplete. PortfolioBlog therefore stores user uploads in Supabase Storage, while repository-owned pixel art remains a static frontend asset.

This article follows the implemented flow: administrator browser → application API → MediaService → Storage adapter → public object URL. The PostgreSQL article covers database integration. Using Storage does not move this site’s administrator login to Supabase Auth.

Create the bucket together with its access policy

The portfolio-media bucket is public because its objects are public blog images. Its file size limit is 5 MiB, with image/png, image/jpeg and image/webp allowed. The API enforces its own limits as well: bucket restrictions add a boundary at the provider rather than replacing application validation.

Public retrieval does not allow anonymous uploads. Only the server writes objects in this design. However, unpublishing an article does not make an already public image private; anyone retaining its URL may still read it. Private documents require a private bucket and an authorized download or signed-URL workflow, not the public-image path shown here.

Keep the project URL, database password and API key distinct

Supabase__Url=https://YOUR_PROJECT_REF.supabase.co
Supabase__SecretKey=YOUR_SERVER_SECRET
Supabase__Bucket=portfolio-media

Only the backend reads these settings. The URL identifies the project, SecretKey authenticates privileged server requests, and Bucket identifies the object collection. The SQL password does not belong in Storage headers. New sb_secret_... keys are not JWTs and travel in apikey. The legacy service_role JWT also receives a Bearer header. The adapter handles that distinction without exposing either key to the browser.

Give each layer one part of the upload

namespace PortfolioBlog.Core.Abstractions.Interfaces;

public interface IMediaFileStore
{
    Task<string> WriteAsync(ReadOnlyMemory<byte> content, string extension, CancellationToken cancellationToken);
    string? LegacyUrl(string name);
}

Core owns this storage contract. MediaService validates bytes and size. Business/Providers/SupabaseStorageProvider owns HTTP communication. The WebApi controller and middleware own authorization, CSRF and the incoming HTTP file. Changing the provider therefore does not require the profile validator or Angular form to learn Supabase authentication headers.

Validate the file before trusting its name

public const int MaximumBytes = 5 * 1024 * 1024;

// Inside the read loop: enforce the actual byte count, not just IFormFile.Length.
if (content.Length + read > MaximumBytes)
    return Invalid();

// WebP additionally needs RIFF at offset 0 and WEBP at offset 8.
if (extension == ".webp" && !bytes.AsSpan(8, 4).SequenceEqual("WEBP"u8))
    return Invalid();

The client filename never becomes a storage path. Extensions are lowercased, jpeg is normalized to jpg, and unsupported extensions are rejected. An empty or oversized declared length fails early. The read loop still checks the actual byte count, because HTTP metadata is not an independent guarantee of the stream’s length.

PNG/JPEG signatures are checked, and WebP must contain both RIFF and WEBP markers. This catches basic format mismatches such as HTML renamed to .png. It is not a complete image decoder, malware scanner or decompression-bomb check. A larger upload surface may need decoding and resource limits as separate controls; claiming that signature checks solve those problems would conceal their actual scope.

The controller allows a 6 MiB request envelope because multipart metadata also takes space; the file itself remains limited to 5 MiB in MediaService. Those limits answer different questions. Selecting the same image repeatedly also creates separate objects: this implementation does not deduplicate by content hash.

Configure a reusable HTTP adapter

var client = new HttpClient(new SocketsHttpHandler
{
    AllowAutoRedirect = false,
    PooledConnectionLifetime = TimeSpan.FromMinutes(5)
})
{
    BaseAddress = new Uri(options.Url.TrimEnd('/') + "/"),
    Timeout = TimeSpan.FromSeconds(30)
};

The application reuses HttpClient. A trailing slash on BaseAddress keeps relative object paths rooted correctly. Redirect following is disabled so privileged headers do not follow an unexpected target. Timeout bounds the provider call; the CancellationToken additionally propagates cancellation from the caller.

This deliberately narrow example expects already authorized, validated PNG bytes. It checks size, HTTPS and bucket naming but is not the entire upload security boundary. In the actual application MediaService establishes the content precondition before the adapter is called.

using System.Net.Http.Headers;

public static class StorageUpload
{
    // Call after application authorization and content/size validation.
    // The caller owns and reuses an HttpClient with an HTTPS project BaseAddress.
    public static async Task<Uri> UploadValidatedPngAsync(
        HttpClient client,
        string bucket,
        string serverKey,
        ReadOnlyMemory<byte> bytes,
        CancellationToken cancellationToken)
    {
        ArgumentNullException.ThrowIfNull(client.BaseAddress);
        if (client.BaseAddress.Scheme != Uri.UriSchemeHttps)
            throw new ArgumentException("An HTTPS project URL is required.");
        if (!System.Text.RegularExpressions.Regex.IsMatch(bucket, "^[a-z0-9][a-z0-9-]{0,62}$"))
            throw new ArgumentException("Invalid bucket name.");
        if (bytes.IsEmpty || bytes.Length > 5 * 1024 * 1024)
            throw new ArgumentException("Invalid file size.");

        var objectPath = $"uploads/{Guid.NewGuid():N}.png";
        using var request = new HttpRequestMessage(HttpMethod.Post, $"storage/v1/object/{bucket}/{objectPath}");
        request.Headers.Add("apikey", serverKey);
        if (!serverKey.StartsWith("sb_secret_", StringComparison.Ordinal))
            request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", serverKey);
        request.Headers.Add("x-upsert", "false");
        request.Content = new ReadOnlyMemoryContent(bytes);
        request.Content.Headers.ContentType = new MediaTypeHeaderValue("image/png");
        using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
        if (!response.IsSuccessStatusCode)
            throw new HttpRequestException($"Storage upload failed ({(int)response.StatusCode}).", null, response.StatusCode);

        return new Uri(client.BaseAddress, $"storage/v1/object/public/{bucket}/{objectPath}");
    }
}

A generated GUID prevents a user-controlled path from becoming the object key. x-upsert=false avoids replacing an existing object. A new cover receives a new URL, avoiding stale caches caused by replacing the bytes at an old address. No public URL is returned until the provider reports success. Failure responses do not forward its response body or server key to the visitor.

The browser talks to the application API

// The administrator is already signed in on this same origin.
const sessionResponse = await fetch('/api/auth/session', {
  credentials: 'same-origin'
});
if (!sessionResponse.ok) throw new Error('Cannot read session.');
const session = (await sessionResponse.json()).data;
const form = new FormData();
form.append('file', selectedFile);
const response = await fetch('/api/admin/media', {
  method: 'POST',
  credentials: 'same-origin',
  headers: { 'X-CSRF-TOKEN': session.csrfToken },
  body: form
});
const result = await response.json();
if (!response.ok || !result.success) throw new Error('Upload failed.');
const imageUrl = result.data; // Persist this URL in the article with a separate save.

This example assumes the administrator is already authenticated on the same origin. JavaScript does not read the HttpOnly session cookie; the browser attaches it. The CSRF token is obtained from the session endpoint and included in the write request. Do not manually set Content-Type for FormData, because the browser supplies the multipart boundary. Angular’s typed API service and interceptor implement the same contract in the real frontend.

An uploaded object and a saved article are separate outcomes

After upload, the returned URL is attached to the article and persisted in a separate administrator save. A PostgreSQL transaction cannot roll back an HTTP upload that already completed. A failed save, conflict or abandoned editor may leave an unreferenced object. A timed-out upload is also ambiguous: Storage might have committed the file even though the response never arrived.

The current site has no blind automatic upload retry or cleanup worker. A larger workload could add stable operation identifiers, recorded object metadata and reference-aware cleanup. If multiple articles share an image, deleting one article must not immediately delete that object. For a small blog, deleting a still-referenced cover can be more damaging than retaining a few unused objects.

Preserve old URLs and back up the actual bytes

Legacy /uploads/{guid}.png URLs are still supported. The API accepts only the expected GUID/extension pattern and returns a 301 to the corresponding Storage URL; it no longer serves a local file. New uploads return the HTTPS public URL directly. The legacy input is not combined with a filesystem path, so it does not become a generic file reader.

The migration moved two legacy images under their existing names and compared downloaded bytes. A database backup was not assumed to contain these objects. npm run db:backup writes site.json, security-configuration.json and uploaded file copies into one private backup directory. The key-ring export and content backup are not published to the public image bucket.

Exercise the failure paths

  • Unauthenticated uploads return 401; authenticated writes without CSRF are rejected. Public image reads do not require an administrator session.
  • Oversized files, unsupported extensions and signature mismatches fail before Storage is called.
  • A Storage 5xx must never produce a successful image URL. The HTTP fixture tests that without uploading into the real bucket.
  • A valid legacy name redirects; an invalid name cannot turn the endpoint into arbitrary file access.
  • The real integration was also checked through an API upload followed by a byte-for-byte download comparison.

An upload can look like one POST request. The surrounding design becomes understandable when it identifies who validates the input and which later operation actually makes the article reference durable.

References

✳ You have reached the end of this orbit.
Explore more articles →
turgay.A little code, a few pixels, a lot of curiosity.© 2026 · Made on Earth ✳