Amazon S3 is the first AWS service most .NET developers touch, and for good reason: almost every real application eventually needs to store files somewhere that is not the web server’s disk. In this guide, I will build an ASP.NET Core Web API on .NET 10 that creates and deletes S3 buckets, uploads files, lists them with presigned URLs, streams downloads, and deletes objects - all using AWS SDK for .NET V4, the current major version of the SDK.
I originally wrote this article in 2022 against .NET 6 and SDK V3. This is a full rewrite for 2026: Minimal APIs instead of controllers, Scalar instead of Swagger, V4 packages with their breaking changes called out, and the security defaults AWS actually ships today. If you are coming from V3, the What Changed in AWS SDK V4 section alone will save you a debugging session.
The complete source code is in my .NET on AWS series repository on GitHub, under the working-with-aws-s3-using-aspnet-core folder.
What is Amazon S3?
Amazon S3 (Simple Storage Service) is AWS’s object storage service. You store files as objects inside buckets, and each object is addressed by a key - the full path-like name of the file, such as invoices/2026/june.pdf. S3 gives you 11 nines of durability, effectively unlimited capacity, and you pay only for what you store and transfer.
Two things about S3 trip up developers who think of it as a file system:
- There are no real folders. The
invoices/2026/part of a key is just a prefix. The console renders prefixes as folders, but S3 itself stores a flat list of keys. - Buckets and objects are private by default. New buckets ship with Block Public Access enabled and ACLs disabled (the
Bucket owner enforcedsetting). If you find a 2022-era tutorial settingCannedACLon uploads, that code path is dead on modern buckets - access is controlled through IAM and bucket policies now, per the S3 Object Ownership documentation.
If you want a wider map of AWS services worth knowing as a .NET developer, I keep one here:
Essential AWS Services Every .NET Developer Should Master
A practical tour of the AWS services that actually show up in .NET projects, and what each one is for.
Prerequisites
- An AWS account (the free tier covers everything in this tutorial - 5 GB of S3 storage for 12 months)
- .NET 10 SDK
- AWS CLI installed and configured
- Any IDE - I am using Visual Studio 2026
Amazon S3 & ASP.NET Core Web API - Upload, Download & Delete Files from S3 Bucket
The video above walks through an earlier .NET version of this build. The flow is the same; the code below is the current .NET 10 + SDK V4 version.
Setting Up AWS Credentials for Local Development
For local development, the cleanest setup is an IAM user with scoped S3 permissions and a named CLI profile. Do not attach AmazonS3FullAccess out of habit - scope the policy to the bucket you are working with:
{"Version": "2012-10-17","Statement": [{"Effect": "Allow","Action": ["s3:ListAllMyBuckets", "s3:CreateBucket", "s3:DeleteBucket"],"Resource": "*"},{"Effect": "Allow","Action": ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject"],"Resource": ["arn:aws:s3:::cwm-dotnet-bucket", "arn:aws:s3:::cwm-dotnet-bucket/*"]}]}Generate an access key for that user, then store it in a local profile:
awsconfigure--profiles3-dotnet-demoThe CLI prompts for the access key, secret key, and default region, and writes them to the credentials store under your user folder. Your application code never sees the keys.
In production, skip access keys entirely. When your API runs on EC2, ECS, EKS, or Lambda, attach an IAM role to the compute resource and the SDK picks up temporary credentials automatically through the same credential chain. Long-lived access keys on a server are the most common S3 security failure I see, and they are never necessary inside AWS.
I wrote a dedicated guide covering every credential option - profiles, environment variables, IAM roles, and how the SDK resolves them:
Configuring AWS Credentials for .NET Applications
The full credential chain explained - local profiles for development, IAM roles for production, and the order the SDK searches.
Creating an S3 Bucket from the AWS Console
Quick console detour so you can see what the API will be talking to. In the S3 console, hit Create bucket and you only need two decisions:
- Bucket name - globally unique across all AWS accounts, lowercase, no underscores. I am using
cwm-dotnet-bucket. - Region - pick the one closest to your users. I am on
ap-south-1. Your SDK client region must match this later.
Leave everything else on defaults. That means ACLs disabled, Block Public Access on, and SSE-S3 encryption at rest - all correct for this tutorial. Private objects with expiring presigned URLs (coming below) beat public buckets in almost every design.
I will create and delete buckets from .NET in a minute, so one console bucket is all you need.
Building the ASP.NET Core Web API
Project Setup
Create a new Web API project:
dotnetnewwebapi-nAwsS3.Api--frameworknet10.0cdAwsS3.ApiInstall the AWS packages. These are the exact versions I am using, both from the V4 generation of the SDK:
dotnetaddpackageAWSSDK.S3--version4.0.24.4dotnetaddpackageAWSSDK.Extensions.NETCore.Setup--version4.0.4.7dotnetaddpackageScalar.AspNetCore--version2.16.3AWSSDK.S3 is the S3 client itself. AWSSDK.Extensions.NETCore.Setup wires the SDK into ASP.NET Core’s configuration and dependency injection. Never mix V3 (3.7.x) and V4 (4.x) AWSSDK packages in one project - the SDK team explicitly warns against it.
Point appsettings.json at the CLI profile you created:
{"Logging": {"LogLevel": {"Default": "Information","Microsoft.AspNetCore": "Warning"}},"AllowedHosts": "*","AWS": {"Profile": "s3-dotnet-demo","Region": "ap-south-1"}}Registering IAmazonS3 with Dependency Injection
The recommended way to use the S3 client in ASP.NET Core is to register IAmazonS3 through AddAWSService, which reads the AWS configuration section and manages the client lifetime for you. Here is the full Program.cs:
usingAmazon.S3;usingAwsS3.Api;usingAwsS3.Api.Endpoints;usingScalar.AspNetCore;varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddOpenApi();builder.Services.AddProblemDetails();builder.Services.AddExceptionHandler<AmazonS3ExceptionHandler>();builder.Services.AddDefaultAWSOptions(builder.Configuration.GetAWSOptions());builder.Services.AddAWSService<IAmazonS3>();varapp=builder.Build();app.UseExceptionHandler();app.MapOpenApi();app.MapScalarApiReference();app.MapBucketEndpoints();app.MapFileEndpoints();app.Run();AddDefaultAWSOptions loads the profile and region from configuration. AddAWSService<IAmazonS3>() registers the client as a singleton, which is exactly what you want - the S3 client is thread-safe and expensive to construct. From here, any endpoint can take IAmazonS3 as a parameter and the container hands it over.
AmazonS3ExceptionHandler is a global exception handler I will show you shortly - it converts S3 errors into proper HTTP responses so the endpoints stay clean.
Bucket Operations with the .NET AWS SDK
I am grouping the bucket endpoints in a static class, Endpoints/BucketEndpoints.cs:
usingAmazon.S3;usingAmazon.S3.Util;namespaceAwsS3.Api.Endpoints;publicstaticclassBucketEndpoints{publicstaticIEndpointRouteBuilderMapBucketEndpoints(thisIEndpointRouteBuilderapp){vargroup=app.MapGroup("/buckets").WithTags("Buckets");group.MapPost("/{bucketName}", async (stringbucketName, IAmazonS3s3, CancellationTokenct) =>{if (awaitAmazonS3Util.DoesS3BucketExistV2Async(s3, bucketName)){returnResults.Conflict($"Bucket {bucketName} already exists.");}awaits3.PutBucketAsync(bucketName, ct);returnResults.Created($"/buckets/{bucketName}", bucketName);});group.MapGet("/", async (IAmazonS3s3, CancellationTokenct) =>{varresponse=awaits3.ListBucketsAsync(ct);// V4: collections are null when empty, not an empty listvarbuckets=response.Buckets?.Select(b=>b.BucketName) ?? [];returnResults.Ok(buckets);});group.MapDelete("/{bucketName}", async (stringbucketName, IAmazonS3s3, CancellationTokenct) =>{awaits3.DeleteBucketAsync(bucketName, ct);returnResults.NoContent();});returnapp;}}Code Walkthrough
POST /buckets/{bucketName} creates a bucket. The existence check uses AmazonS3Util.DoesS3BucketExistV2Async - the old DoesS3BucketExistAsync client method was removed in V4. The bucket lands in whatever region the client is configured for.
GET /buckets lists every bucket in the account. Note the ?? [] guard: in SDK V4, an empty result means response.Buckets is null, not an empty list. Code migrated straight from V3 throws a NullReferenceException here, and it will not show up until you hit an account with no buckets.
DELETE /buckets/{bucketName} removes a bucket - but S3 only deletes empty buckets. If objects remain, the SDK throws an AmazonS3Exception with the BucketNotEmpty error code, which my exception handler turns into a 409 Conflict.
Every endpoint takes a CancellationToken and passes it to the SDK call. If the client disconnects mid-request, the S3 call is cancelled instead of running to completion for nobody.
File Operations in AWS S3 using ASP.NET Core
Now the part you came for. Endpoints/FileEndpoints.cs handles upload, list, download, and delete, all grouped under /buckets/{bucketName}/files.
How to Upload Files to AWS S3 from ASP.NET Core?
group.MapPost("/", async (stringbucketName, IFormFilefile, string? prefix, IAmazonS3s3, CancellationTokenct) =>{varkey=string.IsNullOrWhiteSpace(prefix)?file.FileName:$"{prefix.TrimEnd('/')}/{file.FileName}";varrequest=newPutObjectRequest{BucketName=bucketName,Key=key,InputStream=file.OpenReadStream(),ContentType=file.ContentType};awaits3.PutObjectAsync(request, ct);returnResults.Created($"/buckets/{bucketName}/files/{key}", key);}).DisableAntiforgery();The important detail: file.OpenReadStream() goes straight into InputStream. The file streams from the HTTP request to S3 without ever being buffered into a MemoryStream. Most S3 tutorials copy the upload into memory first, which works in a demo and falls over the day someone uploads a 500 MB video - your server allocates 500 MB per concurrent upload.
Two more things worth knowing:
DisableAntiforgery()is required on .NET 8 and later. Minimal API endpoints that bindIFormFileenforce antiforgery validation by default, and without this call (or a proper antiforgery token setup for browser forms) every upload fails with a 400.- Set
ContentTypeon the request. S3 stores it as object metadata, and it becomes theContent-Typeheader when the object is downloaded or served through a presigned URL. Skip it and everything comes back asapplication/octet-stream.
The SDK also computes an integrity checksum (CRC64-NVME) for every upload automatically since the V4 generation - more on that in the V4 section.
How to List Files in an S3 Bucket with Presigned URLs?
Objects in a private bucket are not reachable by URL. The standard pattern for showing files to users is to list the objects and attach a presigned URL to each one - a time-limited link signed with your credentials that grants temporary read access:
group.MapGet("/", async (stringbucketName, string? prefix, IAmazonS3s3, CancellationTokenct) =>{varresponse=awaits3.ListObjectsV2Async(newListObjectsV2Request{BucketName=bucketName,Prefix=prefix}, ct);// V4: S3Objects is null when the bucket has no matching objectsvarobjects=response.S3Objects?? [];varfiles=newList<S3ObjectResponse>();foreach (vars3Objectinobjects){varpresignedUrl=awaits3.GetPreSignedURLAsync(newGetPreSignedUrlRequest{BucketName=bucketName,Key=s3Object.Key,Expires=DateTime.UtcNow.AddMinutes(10)});files.Add(newS3ObjectResponse(s3Object.Key, s3Object.Size, presignedUrl));}returnResults.Ok(files);});With the response shaped by a small record:
namespaceAwsS3.Api.Models;// V4: Size is long? because the SDK moved value-type properties to nullablespublicsealedrecordS3ObjectResponse(stringKey, long? SizeInBytes, stringPresignedUrl);The Prefix parameter is how you filter by “folder” - pass invoices and you get only keys starting with that prefix. Presigned URLs generated with an IAM user’s credentials can live up to 7 days; URLs signed by temporary role credentials die when those credentials expire, per the AWS presigned URL documentation. Ten minutes is a sane default for a file listing.
Presigned URLs go much deeper than this - direct browser uploads, PUT URLs, content-type pinning. I cover the full pattern separately:
AWS S3 Presigned URLs for .NET
Secure file uploads and downloads without exposing your bucket - direct browser uploads, expiry strategy, and the security caveats.
How to Download Files from AWS S3 in ASP.NET Core?
group.MapGet("/{*key}", async (stringbucketName, stringkey, IAmazonS3s3, CancellationTokenct) =>{varresponse=awaits3.GetObjectAsync(bucketName, key, ct);// Streams straight from S3 to the client. No MemoryStream, no buffering.returnResults.Stream(response.ResponseStream, response.Headers.ContentType, Path.GetFileName(key));});Same streaming principle as the upload, in reverse. Results.Stream pipes ResponseStream directly to the HTTP response, so a 2 GB download costs your server a small buffer, not 2 GB of RAM. The {*key} catch-all route parameter matters because S3 keys contain slashes - invoices/2026/june.pdf would not match a plain {key} segment.
If the key does not exist, GetObjectAsync throws an AmazonS3Exception with error code NoSuchKey - handled globally below, returned as a 404.
How to Delete Files from AWS S3?
group.MapDelete("/{*key}", async (stringbucketName, stringkey, IAmazonS3s3, CancellationTokenct) =>{awaits3.DeleteObjectAsync(bucketName, key, ct);returnResults.NoContent();});One call, 204 response. Worth knowing: on a bucket without versioning, this is permanent. If accidental deletes worry you, S3 versioning keeps every overwritten and deleted object recoverable - I walk through it in S3 Versioning in .NET.
Handling AmazonS3Exception with ProblemDetails
Notice the endpoints have no try/catch noise and no repeated “does the bucket exist” checks before every operation. That is deliberate: the extra existence check costs a network round trip per request and still cannot prevent races. Instead, I let the SDK throw and translate the error once, globally, using .NET’s IExceptionHandler:
usingAmazon.S3;usingMicrosoft.AspNetCore.Diagnostics;namespaceAwsS3.Api;internalsealedclassAmazonS3ExceptionHandler(IProblemDetailsServiceproblemDetailsService) : IExceptionHandler{publicasyncValueTask<bool> TryHandleAsync(HttpContexthttpContext, Exceptionexception, CancellationTokencancellationToken){if (exceptionisnotAmazonS3Exceptions3Exception){returnfalse;}varstatusCode=s3Exception.ErrorCodeswitch{"NoSuchBucket"or"NoSuchKey"=>StatusCodes.Status404NotFound,"AccessDenied"or"InvalidAccessKeyId"or"SignatureDoesNotMatch"=>StatusCodes.Status403Forbidden,"BucketAlreadyExists"or"BucketAlreadyOwnedByYou"or"BucketNotEmpty"=>StatusCodes.Status409Conflict,_=>StatusCodes.Status500InternalServerError};httpContext.Response.StatusCode=statusCode;returnawaitproblemDetailsService.TryWriteAsync(newProblemDetailsContext{HttpContext=httpContext,ProblemDetails={Status=statusCode,Title=s3Exception.ErrorCode,Detail=s3Exception.Message}});}}Upload to a bucket that does not exist and the client gets a clean RFC 9457 ProblemDetails response with a 404 and the NoSuchBucket error code, instead of a raw 500 with a stack trace. Every S3 endpoint in the API gets this behavior for free.
Run the project with dotnet run and open /scalar/v1 - Scalar gives you an interactive API reference where you can create a bucket, push a file in, list it, copy the presigned URL into a browser tab, and watch it expire after ten minutes.
What Changed in AWS SDK for .NET V4?
AWS SDK for .NET V4 went GA in April 2025, and it is the version every new project should use. If you are migrating S3 code from V3, these are the changes that actually bite, condensed from the official migration guide:
- Value-type properties are nullable.
GetObjectResponse.ContentLengthis nowlong?,S3Object.Sizeislong?, and so on. Code doing arithmetic on these needs null handling. - Empty collections are null.
ListObjectsV2AsyncreturnsS3Objects = nullwhen nothing matches,ListBucketsAsyncreturnsBuckets = nullon an empty account. Guard with?? []or setAWSConfigs.InitializeCollections = truefor V3-style behavior. - Checksums are automatic. The SDK computes a CRC64-NVME checksum on every
PutObjectand S3 validates it server-side, per the data integrity documentation. The oldCalculateContentMD5Headerproperty is gone. This is free corruption protection on AWS, but it breaks some S3-compatible stores - see Troubleshooting. DoesS3BucketExistAsyncwas removed. UseAmazonS3Util.DoesS3BucketExistV2Async.- SigV4 is the only signing mode. All the
UseSignatureVersion4toggles are gone. - Region matching is strict. A client configured for
us-east-1can no longer transparently reach a bucket ineu-west-1. Mismatches fail - see Troubleshooting.
The DI story (AddDefaultAWSOptions + AddAWSService) is unchanged from V3, and the V4 AWSSDK.Extensions.NETCore.Setup package is Native AOT compatible on .NET 8 and later.
Which S3 Upload Approach Should You Use?
There are four reasonable ways to get a file into S3 from a .NET system. Here is my decision matrix:
| Approach | Best for | Avoid when |
|---|---|---|
PutObjectAsync (this article) | API-mediated uploads up to ~100 MB, single round trip, full server-side control | Files are large or your API’s bandwidth is the bottleneck |
TransferUtility | Server-side uploads of large files - automatically switches to multipart above 16 MB | You need per-part control or progress reporting beyond its events |
| Presigned PUT URLs | Browser/mobile clients uploading directly to S3, zero load on your API | You must inspect or transform file content before it is stored |
| Manual multipart upload | Files over 100 MB, resumable uploads, parallel part uploads | Anything small - the orchestration is not worth it |
My take: route uploads through your API (like this article does) only when you genuinely need to validate, scan, or transform the file server-side. The moment files exceed ~100 MB, or upload volume starts eating your API’s bandwidth, switch to presigned URLs and let clients talk to S3 directly. That is what S3 is built for, and AWS recommends multipart for anything over 100 MB anyway, per the multipart upload documentation. In the projects I have worked on, the “proxy everything through the API” design is the single most common reason file upload features get rewritten six months in.
For the large-file path, I have a dedicated walkthrough:
Upload Large Files in ASP.NET Core Using S3 Multipart Upload
Chunked, resumable uploads with presigned URLs for each part - the pattern for files in the hundreds of MB and beyond.
Key Takeaways
- Register
IAmazonS3withAddAWSService<IAmazonS3>()fromAWSSDK.Extensions.NETCore.Setup4.0.4.7 - it stays the recommended DI pattern in SDK V4, and the client is a thread-safe singleton. - Stream, never buffer.
IFormFile.OpenReadStream()intoPutObjectRequest.InputStreamon the way up,Results.Stream(response.ResponseStream, ...)on the way down. - SDK V4 returns null collections and nullable value types - guard
S3Objects,Buckets,Size, andContentLength. - Keep buckets private and hand out presigned URLs with short expiries; ACLs are disabled on new buckets and that is the correct default.
- Access keys are for your laptop, IAM roles are for production.
Troubleshooting Common S3 Issues in .NET
NullReferenceException when listing objects or buckets
V3-era code assuming response.S3Objects is an empty list crashes on V4 when the result set is empty, because the collection is null. Use response.S3Objects ?? [], or opt back into old behavior globally with AWSConfigs.InitializeCollections = true at startup.
PermanentRedirect or “bucket is in a different region” errors
V4 enforces region matching: the client’s configured region must be the bucket’s region. Check the bucket’s region in the console and align AWS:Region in appsettings.json. One client per region if you work across regions.
Checksum or BadDigest errors against MinIO, LocalStack, or Cloudflare R2
V4’s automatic checksums assume an S3 endpoint that understands CRC64-NVME. Some S3-compatible stores do not. Configure the client to only send checksums when an operation strictly requires them by setting RequestChecksumCalculation and ResponseChecksumValidation to WHEN_REQUIRED in the client config.
If you test locally against LocalStack, I show the full container-based setup in AWS Local Development with .NET Aspire.
400 Bad Request on every IFormFile upload
Minimal APIs validate antiforgery tokens for form-data endpoints since .NET 8. For a token-authenticated API, add .DisableAntiforgery() to the upload endpoint. For browser form posts, wire up the antiforgery services properly instead.
AccessDenied on upload or download
Your IAM policy is missing the specific action (s3:PutObject, s3:GetObject) on the specific resource. Remember object-level actions need the /* resource ARN (arn:aws:s3:::bucket-name/*), not just the bucket ARN. Presigned URLs inherit this too - a URL signed by credentials without s3:GetObject returns AccessDenied even before it expires.
Profile not found when starting the API
The AWS:Profile value in appsettings.json must exactly match a profile created with aws configure --profile <name>. Run aws configure list-profiles to see what exists on the machine.
Frequently Asked Questions
What’s Next in the S3 Series?
This article gave you the foundation: buckets, uploads, downloads, deletes, and the V4 SDK behavior changes. The rest of my S3 series builds on exactly this project layout:
- AWS S3 Presigned URLs for .NET - direct browser uploads and secure sharing
- Upload Large Files with S3 Multipart Upload - chunked, resumable uploads
- S3 Versioning in .NET - recover from accidental deletes and overwrites
- S3 Lifecycle Policies with .NET - automate storage tiering and cleanup
- Trigger AWS Lambda with S3 Events - react to uploads with serverless processing
- Serverless Image Processing with S3, SQS & Lambda - a full event-driven pipeline
The complete source code for this article lives in the .NET on AWS series repository.
If you found this helpful, share it with your colleagues - and if there’s an S3 topic you’d like covered next, drop a comment and let me know.
Happy Coding :)
