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();
}
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)