
Learn AWS S3 with C# step by step: upload, download, list and delete files, generate presigned URLs, and use S3 in ASP.NET Core. Read the full guide now.
Amazon S3 is where much of the internet keeps its files: user uploads, invoices, backups, logs, static assets and data lake files. If you build on .NET, you'll probably need to use AWS S3 from C# at some point. This AWS S3 C# tutorial covers the whole process. You'll install the AWS SDK for .NET, configure credentials the right way, and upload, download, list and delete objects. You'll also learn presigned URLs, large-file multipart uploads, and a production-ready ASP.NET Core integration.
The guide doesn't just show API calls. It also explains why each approach works, so you can avoid common mistakes like leaked access keys, memory spikes from large files, and surprise AWS bills.
What Is Amazon S3? A Quick Primer for .NET Developers
Amazon Simple Storage Service (S3) is an object storage service. It's not a file system and it's not a database. Some core concepts:
- Bucket: a top-level container with a globally unique name, such as
acme-invoices-prod. It lives in one AWS Region. - Object: the file bytes plus metadata (content type, custom headers, tags).
- Key: the object's full name, such as
invoices/2026/09/inv-1042.pdf. S3 has no real folders. The slashes are part of the key, and the console just displays them as folders. - Storage classes: Standard, Intelligent-Tiering, Standard-IA, Glacier and others. Each one trades cost against retrieval speed.
This matters because it shapes how you write code. S3 has no "rename" operation (you copy, then delete). Listing a "folder" is really a prefix query. Every request costs money, so chatty code gets expensive.
Setting Up the AWS SDK for .NET (S3)
1. Install the NuGet packages
You need the S3 client package. For ASP.NET Core or any app that uses dependency injection, add the extensions package too:
// .NET CLI
dotnet add package AWSSDK.S3
dotnet add package AWSSDK.Extensions.NETCore.Setup // for DI / appsettings.json support
The examples below target AWS SDK for .NET v4 and .NET 8/9/10. Most of the code also works on v3. The differences that can bite you are listed in the pitfalls section.
2. Configure credentials (never hardcode keys)
The most dangerous S3 mistake is pasting an access key and secret straight into code:
// ❌ DON'T do this — keys end up in Git, logs, and decompiled binaries
var client = new AmazonS3Client("AKIA...", "wJalrXUtn...", RegionEndpoint.USEast1);
Let the SDK's default credential chain find credentials for you. It checks these sources in order: environment variables, the shared ~/.aws/credentials profile (or AWS IAM Identity Center/SSO), and then the IAM role attached to your EC2 instance, ECS task or Lambda function. On your machine, run aws configure sso or aws configure. In production, attach an IAM role. Your code stays the same in both places:
using Amazon;
using Amazon.S3;
// ✅ Credentials resolved automatically from environment, profile, or IAM role
var s3 = new AmazonS3Client(RegionEndpoint.USEast1);
Why this matters: role-based credentials are temporary and rotate automatically, and you never have to store them. Leaked long-term keys are one of the most common ways AWS accounts get compromised.
How to Upload a File to S3 in C#
For small and medium files, PutObjectAsync is the simplest option:
using Amazon.S3;
using Amazon.S3.Model;
public static async Task UploadFileAsync(IAmazonS3 s3, string bucket, string key, string filePath)
{
var request = new PutObjectRequest
{
BucketName = bucket,
Key = key, // e.g. "reports/2026/q3-summary.pdf"
FilePath = filePath,
ContentType = "application/pdf",
ServerSideEncryptionMethod = ServerSideEncryptionMethod.AES256
};
// Custom metadata is stored with the object (sent as x-amz-meta-* headers)
request.Metadata.Add("uploaded-by", "reporting-service");
PutObjectResponse response = await s3.PutObjectAsync(request);
Console.WriteLine($"Uploaded {key} — HTTP {(int)response.HttpStatusCode}, ETag {response.ETag}");
}
You can also upload directly from a Stream, such as a generated file or an HTTP upload, by setting InputStream instead of FilePath:
await using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("Hello from C#!"));
await s3.PutObjectAsync(new PutObjectRequest
{
BucketName = "my-app-bucket",
Key = "greetings/hello.txt",
InputStream = stream,
ContentType = "text/plain"
});
Always set ContentType. If you leave it out, S3 may serve the object as application/octet-stream. Browsers will then download your images and PDFs instead of displaying them.
Uploading Large Files: TransferUtility and Multipart Uploads
A single PutObject call can upload up to 5 GB. In practice, anything over roughly 100 MB should use multipart upload. The file is split into parts that upload in parallel, a failed part can be retried on its own, and throughput is much higher. You don't have to write this yourself. TransferUtility does it for you:
using Amazon.S3.Transfer;
public static async Task UploadLargeFileAsync(IAmazonS3 s3, string bucket, string key, string filePath)
{
var transfer = new TransferUtility(s3);
var request = new TransferUtilityUploadRequest
{
BucketName = bucket,
Key = key,
FilePath = filePath,
PartSize = 16 * 1024 * 1024, // 16 MB parts
StorageClass = S3StorageClass.IntelligentTiering
};
request.UploadProgressEvent += (_, e) =>
Console.Write($"\rUploading {key}: {e.PercentDone}% ");
await transfer.UploadAsync(request);
Console.WriteLine("\nDone.");
}
Tip: add an S3 lifecycle rule that aborts incomplete multipart uploads after about 7 days. If an upload crashes partway, the parts already uploaded stay in the bucket and you keep paying to store them. They don't appear in normal object listings, which makes this a common hidden cost.
How to Download a File from S3 in C#
GetObjectAsync returns a response containing a live network stream. Dispose the response when you're done. If you don't, you'll leak HTTP connections, and under load your app will eventually stall waiting for a free connection.
public static async Task DownloadFileAsync(IAmazonS3 s3, string bucket, string key, string destinationPath)
{
using GetObjectResponse response = await s3.GetObjectAsync(bucket, key);
Console.WriteLine($"Content-Type: {response.Headers.ContentType}, Size: {response.ContentLength} bytes");
// Streams straight to disk — never loads the whole object into memory
await response.WriteResponseStreamToFileAsync(destinationPath, append: false, CancellationToken.None);
}
public static async Task ReadTextObjectAsync(IAmazonS3 s3, string bucket, string key)
{
using var response = await s3.GetObjectAsync(bucket, key);
using var reader = new StreamReader(response.ResponseStream);
return await reader.ReadToEndAsync();
}
Don't read multi-gigabyte objects into a byte[] or MemoryStream. Stream them to disk or pipe them to the HTTP response instead. For very large downloads, TransferUtility.DownloadAsync can use parallel ranged requests.
Listing Objects in an S3 Bucket (with Pagination)
Each ListObjectsV2 call returns at most 1,000 keys. A common bug is code that works in development with 50 files and silently misses data in production with 50,000. The SDK's paginators handle continuation tokens for you:
public static async Task ListObjectsAsync(IAmazonS3 s3, string bucket, string prefix)
{
var request = new ListObjectsV2Request
{
BucketName = bucket,
Prefix = prefix // e.g. "invoices/2026/" acts like a folder
};
// Paginator handles ContinuationToken automatically across all pages
await foreach (S3Object obj in s3.Paginators.ListObjectsV2(request).S3Objects)
{
Console.WriteLine($"{obj.Key,-60} {obj.Size,12:N0} bytes {obj.LastModified:u}");
}
}
Set Delimiter = "/" to get a folder-style view. The immediate "subfolders" come back in CommonPrefixes and the files at that level come back in S3Objects.
Deleting and Copying Objects
// Delete a single object
await s3.DeleteObjectAsync("my-app-bucket", "greetings/hello.txt");
// Batch delete — up to 1,000 keys per request (far cheaper than 1,000 calls)
var batch = new DeleteObjectsRequest { BucketName = "my-app-bucket" };
batch.Objects = new List<KeyVersion>
{
new() { Key = "temp/a.json" },
new() { Key = "temp/b.json" }
};
DeleteObjectsResponse result = await s3.DeleteObjectsAsync(batch);
// "Rename" = copy + delete (S3 has no native rename/move)
await s3.CopyObjectAsync("my-app-bucket", "drafts/report.pdf", "my-app-bucket", "final/report.pdf");
await s3.DeleteObjectAsync("my-app-bucket", "drafts/report.pdf");
S3 Presigned URLs in C#: Secure Direct Uploads and Downloads
Presigned URLs are one of the most useful S3 features. Your API creates a short-lived signed URL, and the browser or mobile app talks to S3 directly. Your server never handles the file bytes. That cuts bandwidth and memory use on your servers and makes large uploads much more scalable.
public static async Task<string> CreateDownloadUrlAsync(IAmazonS3 s3, string bucket, string key)
{
var request = new GetPreSignedURLRequest
{
BucketName = bucket,
Key = key,
Verb = HttpVerb.GET,
Expires = DateTime.UtcNow.AddMinutes(15)
};
return await s3.GetPreSignedURLAsync(request);
}
public static async Task<string> CreateUploadUrlAsync(IAmazonS3 s3, string bucket, string key, string contentType)
{
var request = new GetPreSignedURLRequest
{
BucketName = bucket,
Key = key,
Verb = HttpVerb.PUT,
ContentType = contentType, // client MUST send the same Content-Type header
Expires = DateTime.UtcNow.AddMinutes(10)
};
return await s3.GetPreSignedURLAsync(request);
}
Why keep expiry short? Anyone who has a presigned URL can use it until it expires. Also, a URL signed with temporary role credentials stops working when those credentials expire, even if the Expires value is later. Generate the key on the server (for example uploads/{userId}/{Guid.NewGuid()}.jpg). Never let the client choose arbitrary keys.
ASP.NET Core S3 File Upload: Production-Ready Setup
In a web app, register IAmazonS3 as a singleton through dependency injection. The client is thread-safe and meant to be reused. Creating a new client per request wastes resources and can exhaust sockets.
// appsettings.json
// {
// "AWS": { "Region": "us-east-1" },
// "Storage": { "BucketName": "my-app-bucket" }
// }
using Amazon.S3;
using Amazon.S3.Model;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());
builder.Services.AddAWSService<IAmazonS3>(); // singleton, credentials via default chain
var app = builder.Build();
string bucket = builder.Configuration["Storage:BucketName"]!;
app.MapPost("/files", async (IFormFile file, IAmazonS3 s3, CancellationToken ct) =>
{
if (file.Length == 0 || file.Length > 20 * 1024 * 1024)
return Results.BadRequest("File must be between 1 byte and 20 MB.");
string key = $"uploads/{DateTime.UtcNow:yyyy/MM/dd}/{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
await using Stream stream = file.OpenReadStream();
await s3.PutObjectAsync(new PutObjectRequest
{
BucketName = bucket,
Key = key,
InputStream = stream,
ContentType = file.ContentType
}, ct);
return Results.Created($"/files/{Uri.EscapeDataString(key)}", new { key });
}).DisableAntiforgery();
app.MapGet("/files/{*key}", async (string key, IAmazonS3 s3) =>
{
try
{
using var meta = await s3.GetObjectMetadataAsync(bucket, key);
string url = await s3.GetPreSignedURLAsync(new GetPreSignedURLRequest
{
BucketName = bucket, Key = key, Verb = HttpVerb.GET,
Expires = DateTime.UtcNow.AddMinutes(5)
});
return Results.Redirect(url);
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return Results.NotFound();
}
});
app.Run();
A few design choices in this code are worth pointing out. The key is built from a GUID rather than the user's file name, which prevents path tricks and overwrites. The size limit is enforced before any S3 call. The download endpoint redirects to a presigned URL, so S3 serves the bytes instead of your app. DisableAntiforgery() is only there to keep the demo simple. For a browser form, leave antiforgery on. For an API, protect the endpoint with authentication.
Handling S3 Errors Properly
Every S3 service error surfaces as an AmazonS3Exception. Branch on StatusCode or ErrorCode rather than parsing the message text:
try
{
using var obj = await s3.GetObjectAsync("my-app-bucket", "missing.txt");
}
catch (AmazonS3Exception ex) when (ex.ErrorCode == "NoSuchKey")
{
Console.WriteLine("Object does not exist.");
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
Console.WriteLine("Access denied — check the IAM policy and bucket policy.");
}
The SDK already retries throttling (503 SlowDown) and transient network errors with exponential backoff. Don't wrap every call in your own retry loop, because you'll multiply the number of retries. If you need different behavior, adjust AmazonS3Config.MaxErrorRetry or the RetryMode.
AWS S3 C# Best Practices
- Use least-privilege IAM policies. Grant only the actions you need, such as
s3:GetObjectands3:PutObject, and only on specific bucket ARNs or prefixes. Avoids3:*. - Keep Block Public Access turned on. Serve private content through presigned URLs or CloudFront with Origin Access Control, not public buckets.
- Reuse one
AmazonS3Clientfor the lifetime of the app. - Always pass a
CancellationTokenin web apps so abandoned requests stop using bandwidth. - Use lifecycle rules to move old data to cheaper storage classes and to abort incomplete multipart uploads.
- Turn on versioning for business-critical buckets so an accidental overwrite or delete can be undone.
- Spread load across key prefixes for very high request rates. S3 supports about 3,500 writes and 5,500 reads per second per prefix.
- Test locally with LocalStack or MinIO by setting
ServiceURLandForcePathStyle = trueonAmazonS3Config.
Common Pitfalls (and How to Avoid Them)
Null collections in SDK v4
In AWS SDK for .NET v4, collection properties on responses default to null instead of empty lists, and many value-type properties became nullable (for example IsTruncated is bool?). Code upgraded from v3 like response.S3Objects.Count can throw NullReferenceException on an empty bucket. Use null-safe checks (response.S3Objects?.Count ?? 0, response.IsTruncated == true), or use the paginators, which handle this for you.
Checksum errors with S3-compatible providers
Recent SDK versions add CRC data-integrity checksums to uploads by default. Some S3-compatible services (older MinIO builds, some Cloudflare R2 and Backblaze setups) reject these checksums. If you see unexpected errors against a non-AWS endpoint, set RequestChecksumCalculation = RequestChecksumCalculation.WHEN_REQUIRED on your AmazonS3Config.
Wrong region or bucket name
If the client is configured for a different region than the bucket, you'll get 301 PermanentRedirect errors or extra latency. Create the client with the bucket's region.
Forgetting pagination
Code that makes a single ListObjectsV2 call sees at most 1,000 keys. Use paginators.
Buffering whole files in memory
Calling ToArray() on a large object's stream can crash your app with out-of-memory errors under load. Stream the data instead.
Conclusion: Key Takeaways for Using AWS S3 with C#
With the AWS SDK for .NET, working with AWS S3 in C# is straightforward once the fundamentals are right. To recap:
- Install
AWSSDK.S3. Let the default credential chain and IAM roles handle authentication, and never hardcode keys. - Use
PutObjectAsyncfor normal uploads andTransferUtilityfor large files that need multipart uploads. - Stream downloads and dispose
GetObjectResponse. - Use paginators, because a single list call returns at most 1,000 keys.
- Use short-lived presigned URLs so clients upload and download directly from S3 while your API stays lightweight.
- Register
IAmazonS3as a singleton in ASP.NET Core, and add least-privilege IAM, lifecycle rules and versioning for production.
Start with the console examples above, then move the ASP.NET Core pattern into your own project. Next steps worth exploring are S3 event notifications that trigger AWS Lambda functions written in C#, and CloudFront for global content delivery. Both are covered in upcoming Cloud Computing guides on csharp-coder.com.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment