DEV Community

Cover image for Generating AWS S3 presigned URLs in dotnet without dependencies
TJ-Tronics Systems
TJ-Tronics Systems

Posted on

Generating AWS S3 presigned URLs in dotnet without dependencies

It is a common requirement to generate AWS S3 presigned URLs for secure, time-limited file uploads to AWS S3 and S3 compatible storage buckets like Cloudflare R2, Backblaze B2, DigitalOcean Spaces or Hetzner Object Storage.

If you look up tutorials to generate a presigned PUT URL for a dotnet application you often find dependencies on the AWSSDK.S3 NuGet package.

A look at their GitHub repo confronts you with a large SDK.

That’s fine if you do multiple, complex things with AWS but if you just want to generate a pre-signed url to allow users to upload files or a profile picture you can solve that with just a few lines of code. Keeping unnecessary dependencies out of your application.

To generate the URL there is no server request required.


Example for Cloudflare R2

In your application secrets store the AccessKeyId and AccessKey to your bucket. The R2 AccountId and R2 Bucket Name are a public part of the upload URL, so you don't need to put them into a secrets store.
You find them when creating a new R2 bucket and adding a Cloudflare Account API Token for access.

The following generates a PUT upload URL with locked in Content Type and File Size:


public class S3Service(AppSettings appSettings)
{

    public string GenerateUrl(string fileName, string contentType, long fileSizeInBytes)
    {
        var now = TimeProvider.System.GetUtcNow();
        string datestamp = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
        string amzDate = now.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture);

        string host = $"{appSettings.R2UploadsBucketName}.{appSettings.R2UploadsAccountId}.r2.cloudflarestorage.com";
        string credentialScope = $"{datestamp}/auto/s3/aws4_request";

        // query parameters must be alphabetically sorted!
        var queryParams = new[]
        {
            $"X-Amz-Algorithm=AWS4-HMAC-SHA256",
            $"X-Amz-Credential={WebUtility.UrlEncode($"{appSettings.R2UploadsAccessKeyId}/{credentialScope}")}",
            $"X-Amz-Date={amzDate}",
            $"X-Amz-Expires={appSettings.R2UploadsUrlExpiresInSeconds}",
            $"X-Amz-SignedHeaders=content-length%3Bcontent-type%3Bhost"
        };

        string canonicalQueryString = string.Join("&", queryParams);

        // required format: HTTPMethod \n CanonicalURI \n CanonicalQueryString \n CanonicalHeaders \n SignedHeaders \n HashedPayload
        string canonicalRequest =
            "PUT\n" +
            $"/{fileName}\n" +
            $"{canonicalQueryString}\n" +
            $"content-length:{fileSizeInBytes}\n" +
            $"content-type:{contentType}\n" +
            $"host:{host}\n\n" +
            "content-length;content-type;host\n" +
            "UNSIGNED-PAYLOAD";

        string canonicalRequestHash = HashHex(Encoding.UTF8.GetBytes(canonicalRequest));

        string stringToSign =
            "AWS4-HMAC-SHA256\n" +
            $"{amzDate}\n" +
            $"{credentialScope}\n" +
            $"{canonicalRequestHash}";

        byte[] kDate = HmacSha256(Encoding.UTF8.GetBytes("AWS4" + appSettings.R2UploadsAccessKey), datestamp);
        byte[] kRegion = HmacSha256(kDate, "auto");
        byte[] kService = HmacSha256(kRegion, "s3");
        byte[] kSigning = HmacSha256(kService, "aws4_request");

        // calculate signature
        byte[] signatureBytes = HmacSha256(kSigning, stringToSign);
        string signature = Convert.ToHexString(signatureBytes).ToLowerInvariant();

        return $"https://{host}/{fileName}?{canonicalQueryString}&X-Amz-Signature={signature}";
    }

    private static byte[] HmacSha256(byte[] key, string data) => HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data));
    private static string HashHex(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
}

Enter fullscreen mode Exit fullscreen mode

For other upload providers just change the host string to the one defined by your storage provider.


While developing Component4 we aim at keeping unnecessary dependencies out of our system when they’re not there to solve complex use cases (e.g. Stripe.NET for payment processing). They can increase security risks, slow down builds, and can create complex maintenance problems in the long run.

Top comments (0)