VOOZH about

URL: https://codewithmukesh.com/blog/working-with-aws-s3-using-aspnet-core/

⇱ Working with AWS S3 using ASP.NET Core (.NET 10) - Upload, Download & Delete Files - codewithmukesh


Skip to main content
Article complete

Get one like this every Tuesday at 7 PM IST.

Back to blog
dotnet aws 16 min read Lesson 15/57 Updated

Working with AWS S3 using ASP.NET Core (.NET 10) - Upload, Download & Delete Files

Learn how to upload, download, and delete files in AWS S3 using ASP.NET Core and .NET 10 with AWS SDK V4, Minimal APIs, presigned URLs, and IAM best practices.

Learn how to upload, download, and delete files in AWS S3 using ASP.NET Core and .NET 10 with AWS SDK V4, Minimal APIs, presigned URLs, and IAM best practices.

dotnet aws

aws s3 amazon s3 storage file upload file download presigned urls aws sdk for dotnet aws sdk v4 dotnet 10 aspnet core minimal apis web api iam cloud storage dotnet on aws

👁 Mukesh Murugan
Mukesh Murugan
Software Engineer
Chapter · 15 of 57 Module 4 of 15 Free
View course

AWS for .NET Developers

From dotnet new to docker push — REST, EF Core 10, auth, caching, Clean Architecture, observability. 57 hands-on lessons, source on GitHub.

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:

  1. 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.
  2. Buckets and objects are private by default. New buckets ship with Block Public Access enabled and ACLs disabled (the Bucket owner enforced setting). If you find a 2022-era tutorial setting CannedACL on 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:

Read nextCompanion article

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
YouTube

Amazon S3 & ASP.NET Core Web API - Upload, Download & Delete Files from S3 Bucket

Watch on YouTube

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:

Terminal window
awsconfigure--profiles3-dotnet-demo

The 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:

Read nextCompanion article

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:

Terminal window
dotnetnewwebapi-nAwsS3.Api--frameworknet10.0
cdAwsS3.Api

Install the AWS packages. These are the exact versions I am using, both from the V4 generation of the SDK:

Terminal window
dotnetaddpackageAWSSDK.S3--version4.0.24.4
dotnetaddpackageAWSSDK.Extensions.NETCore.Setup--version4.0.4.7
dotnetaddpackageScalar.AspNetCore--version2.16.3

AWSSDK.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 list
varbuckets=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:

  1. DisableAntiforgery() is required on .NET 8 and later. Minimal API endpoints that bind IFormFile enforce antiforgery validation by default, and without this call (or a proper antiforgery token setup for browser forms) every upload fails with a 400.
  2. Set ContentType on the request. S3 stores it as object metadata, and it becomes the Content-Type header when the object is downloaded or served through a presigned URL. Skip it and everything comes back as application/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 objects
varobjects=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 nullables
publicsealedrecordS3ObjectResponse(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:

Read nextCompanion article

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:

  1. Value-type properties are nullable. GetObjectResponse.ContentLength is now long?, S3Object.Size is long?, and so on. Code doing arithmetic on these needs null handling.
  2. Empty collections are null. ListObjectsV2Async returns S3Objects = null when nothing matches, ListBucketsAsync returns Buckets = null on an empty account. Guard with ?? [] or set AWSConfigs.InitializeCollections = true for V3-style behavior.
  3. Checksums are automatic. The SDK computes a CRC64-NVME checksum on every PutObject and S3 validates it server-side, per the data integrity documentation. The old CalculateContentMD5Header property is gone. This is free corruption protection on AWS, but it breaks some S3-compatible stores - see Troubleshooting.
  4. DoesS3BucketExistAsync was removed. Use AmazonS3Util.DoesS3BucketExistV2Async.
  5. SigV4 is the only signing mode. All the UseSignatureVersion4 toggles are gone.
  6. Region matching is strict. A client configured for us-east-1 can no longer transparently reach a bucket in eu-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:

ApproachBest forAvoid when
PutObjectAsync (this article)API-mediated uploads up to ~100 MB, single round trip, full server-side controlFiles are large or your API’s bandwidth is the bottleneck
TransferUtilityServer-side uploads of large files - automatically switches to multipart above 16 MBYou need per-part control or progress reporting beyond its events
Presigned PUT URLsBrowser/mobile clients uploading directly to S3, zero load on your APIYou must inspect or transform file content before it is stored
Manual multipart uploadFiles over 100 MB, resumable uploads, parallel part uploadsAnything 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:

Read nextCompanion article

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 IAmazonS3 with AddAWSService<IAmazonS3>() from AWSSDK.Extensions.NETCore.Setup 4.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() into PutObjectRequest.InputStream on the way up, Results.Stream(response.ResponseStream, ...) on the way down.
  • SDK V4 returns null collections and nullable value types - guard S3Objects, Buckets, Size, and ContentLength.
  • 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

Frequently asked08 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:

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 :)

More from the archive.

View all articles

What's your take?

Push back, share a war story, or ask the obvious question someone else is wondering. I read every comment.

View on GitHub

Weekly .NET tips · free

Subscribed · Tue 7 PM IST

You're in.
Welcome to the crew.

Tuesday's issue lands in your inbox at 7 PM IST. One last step: confirm your email so it actually arrives.

01 · Check your inbox

02 · Every Tuesday

Benchmarks, architecture insights, and production tips that never make it to the blog.

Privacy notice · 30s read

Cookies, but only the useful ones.

I use cookies to understand which articles get read and which CTAs actually work. No third-party advertising trackers, ever. Read the privacy policy →