S3Lite
July 6, 2026 ยท View on GitHub
S3Lite
Lightweight Amazon S3 and S3-compatible storage client for .NET.
S3Lite keeps the surface area small while still covering the core bucket and object operations most applications actually need. It targets AWS S3, Less3, MinIO, LocalStack, and other S3-compatible endpoints without dragging in the official AWS SDK.
Why S3Lite
- Small dependency footprint
- Simple fluent client configuration
- AWS S3 and S3-compatible endpoint support
- Anonymous access support for public buckets
- Caller-supplied
HttpClientsupport for DI, proxying, custom handlers, and connection reuse - Reverse-proxy / gateway routing that keeps the SigV4 signature bound to the upstream endpoint (useful for non-standard corporate reverse proxy configurations)
- Multi-targeted package:
netstandard2.0,netstandard2.1,net8.0, andnet10.0
New in v1.2.0
- Added reverse-proxy / gateway routing via
GatewayConfigandS3Client.WithGateway(GatewayConfig)
Installation
dotnet add package S3Lite
Quick Start
AWS S3 with Credentials
using System;
using System.Text;
using S3Lite;
using S3Lite.ApiObjects;
S3Client s3 = new S3Client()
.WithRegion("us-west-1")
.WithAccessKey("AKIAIOSFODNN7EXAMPLE")
.WithSecretKey("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
.WithRequestStyle(RequestStyleEnum.VirtualHostedStyle)
.WithLogger(Console.WriteLine);
ListAllMyBucketsResult buckets = await s3.Service.ListBucketsAsync();
await s3.Object.WriteAsync(
"my-bucket",
"hello.txt",
Encoding.UTF8.GetBytes("hello from s3lite"),
"text/plain");
byte[] data = await s3.Object.GetAsync("my-bucket", "hello.txt");
Console.WriteLine(Encoding.UTF8.GetString(data));
Anonymous Access for Public Buckets
If a bucket is public, simply omit credentials:
using System;
using S3Lite;
using S3Lite.ApiObjects;
S3Client s3 = new S3Client()
.WithRegion("us-west-1")
.WithRequestStyle(RequestStyleEnum.VirtualHostedStyle);
ListBucketResult result = await s3.Bucket.ListAsync("public-dataset-bucket");
Console.WriteLine(result.Contents.Count);
S3-Compatible Storage
using S3Lite;
S3Client s3 = new S3Client()
.WithHostname("localhost")
.WithPort(9000)
.WithProtocol(ProtocolEnum.Http)
.WithRegion("us-west-1")
.WithRequestStyle(RequestStyleEnum.PathStyle)
.WithAccessKey("minioadmin")
.WithSecretKey("minioadmin");
Bring Your Own HttpClient
S3Lite now exposes the caller-supplied HttpClient support added in RestWrapper v3.2.0. Use this when you already manage HttpClient instances through dependency injection, need a custom handler pipeline, or want to centralize transport settings.
using System;
using System.Net.Http;
using S3Lite;
HttpClient httpClient = new HttpClient();
httpClient.Timeout = TimeSpan.FromSeconds(30);
S3Client s3 = new S3Client(httpClient)
.WithRegion("us-east-1")
.WithHostname("s3.us-east-1.amazonaws.com")
.WithRequestStyle(RequestStyleEnum.PathStyle)
.WithAccessKey("AKIAIOSFODNN7EXAMPLE")
.WithSecretKey("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
bool exists = await s3.Bucket.ExistsAsync("my-bucket");
You can also attach one fluently:
S3Client s3 = new S3Client()
.WithHttpClient(httpClient)
.WithRegion("us-east-1");
Notes:
- The caller owns the lifetime of the supplied
HttpClient - S3Lite does not dispose a caller-supplied
HttpClient - Transport behavior such as proxying, TLS, decompression, retries, and timeouts should be configured on your
HttpClientor its handler
Routing Through a Reverse Proxy / Gateway
Some environments require S3 traffic to egress through an intermediate host that forwards to the real endpoint. For example, gateway.example.com can be configured to act as a reverse proxy to s3.us-west-1.amazonaws.com.
SigV4 binds the signature to the request Host, so simply pointing the client at the gateway makes it sign for the gateway; once the gateway rewrites Host to the upstream endpoint, the signature no longer validates.
GatewayConfig solves this. When set, S3Lite modifies the HTTP request it is sending over the wire, while the SigV4 signature stays bound to the original destination configured via WithHostname. Attach it with WithGateway:
using S3Lite;
S3Client s3 = new S3Client()
.WithRegion("us-west-1")
.WithHostname("s3.us-west-1.amazonaws.com") // the real upstream endpoint the signature is bound to
.WithPort(443)
.WithProtocol(ProtocolEnum.Https)
.WithRequestStyle(RequestStyleEnum.PathStyle)
.WithAccessKey("AKIAIOSFODNN7EXAMPLE")
.WithSecretKey("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY")
.WithGateway(new GatewayConfig
{
Hostname = "gateway.example.com", // gateway host only, no scheme or port
Port = 8443, // omit (or 0) to reuse the client Port
Protocol = ProtocolEnum.Https, // omit (or null) to reuse the client Protocol
BasePath = "/s3-proxy", // gateway base path prefix (skip if path does not need modification)
PathRewrite = p => "/s3-proxy/" + p, // gateway path rewrite (takes precedence over BasePath)
});
byte[] content = await s3.Object.GetAsync("attachments-bucket", "cat-photo.jpg");
How it works:
- The connection (scheme, host, port) is created for
https://gateway.example.com, so the gateway receivesHost=gateway.example.com. - The signature is computed for the upstream request authority, which is not necessarily
Hostnameverbatim. ForPathStyleit is the endpoint (for examples3.us-west-1.amazonaws.com, including a non-standard port when configured); forVirtualHostedStyleit is bucket-prefixed (for examplemy-bucket.s3.us-west-1.amazonaws.com). - The gateway is expected to undo all the modifications introduced by the
GatewayConfig, which may include the host and path. The query parameters and body are expected to be passed through unmodified; if you happen to be in a situation where the gateway does want to perform such a rewrite, please let us know in an issue.
In other words, your app will send a TCP connection to gateway.example.com with a request like:
GET /s3-proxy/attachments-bucket/cat-photo.jpg HTTP/1.1
Host: gateway.example.com
Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260707/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=xxxxx
x-amz-date: 20260707T143000Z
User-Agent: S3Lite
As written, this request will not validate for s3.us-west-1.amazonaws.com -- it's the wrong domain name and path.
The gateway will then modify the request into a form like this before sending it upstream:
GET /attachments-bucket/cat-photo.jpg
Host: s3.us-west-1.amazonaws.com
Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260707/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=xxxxx
x-amz-date: 20260707T143000Z
User-Agent: S3Lite
This time, the request will validate, because all the extras added by GatewayConfig were precisely undone by the gateway's behavior.
The gateway does not (and cannot) recompute the signature, so it stays unchanged from what the app authored; instead, we have constructed our request so that the signature isn't valid at the start but becomes valid after passing through the gateway.
GatewayConfig.Hostname must be the gateway host only โ supply the protocol via GatewayConfig.Protocol and the port via GatewayConfig.Port separately. When Protocol is null or Port is 0, the client's own Protocol / Port values are reused. Leave Gateway unset (or Hostname empty) to send requests directly to Hostname.
This is not a forward / HTTP (CONNECT) proxy. A forward proxy preserves the upstream Host end-to-end and needs no rewrite; to use one, leave Gateway unset and supply an HttpClient configured with a WebProxy via WithHttpClient instead.
Thanks to @danya02 for contributing this proxy/gateway support.
Common Operations
Service APIs
ListAllMyBucketsResult buckets = await s3.Service.ListBucketsAsync();
Bucket APIs
bool exists = await s3.Bucket.ExistsAsync("my-bucket");
await s3.Bucket.WriteAsync("my-bucket", "us-west-1");
ListBucketResult objects = await s3.Bucket.ListAsync("my-bucket");
ListBucketResult filtered = await s3.Bucket.ListAsync("my-bucket", prefix: "images/");
ListBucketResult page = await s3.Bucket.ListAsync("my-bucket", continuationToken: "token-value", maxKeys: 100);
await s3.Bucket.DeleteAsync("my-bucket");
Object APIs
await s3.Object.WriteAsync("my-bucket", "notes/hello.txt", Encoding.UTF8.GetBytes("hello"));
bool exists = await s3.Object.ExistsAsync("my-bucket", "notes/hello.txt");
ObjectMetadata metadata = await s3.Object.GetMetadataAsync("my-bucket", "notes/hello.txt");
byte[] data = await s3.Object.GetAsync("my-bucket", "notes/hello.txt");
await s3.Object.DeleteAsync("my-bucket", "notes/hello.txt");
Endpoint Guidance
The right hostname depends on the request style you choose:
| Request Style | Typical Hostname | Example URL |
|---|---|---|
VirtualHostedStyle | amazonaws.com | https://mybucket.s3.us-west-1.amazonaws.com/mykey |
PathStyle | s3.us-west-1.amazonaws.com | https://s3.us-west-1.amazonaws.com/mybucket/mykey |
For S3-compatible platforms such as Less3, MinIO, or LocalStack, point Hostname and Port at your service endpoint and usually prefer PathStyle.
Key Client Properties
| Property | Description |
|---|---|
AccessKey | Access key, or null for anonymous mode |
SecretKey | Secret key, or null for anonymous mode |
HasCredentials | True when both access key and secret key are configured |
Region | Region used in request signing and URL construction |
Hostname | Endpoint hostname |
Port | Endpoint port |
Protocol | Http or Https |
RequestStyle | VirtualHostedStyle or PathStyle |
SignatureVersion | Signature version used by the client |
HttpClient | Optional caller-supplied HttpClient instance |
Gateway | Optional GatewayConfig for reverse-proxy / gateway routing while signing for the upstream Hostname |
Logger | Optional request logger callback |
Error Behavior
S3Lite throws WebException for failed requests and includes useful context in the exception Data collection, including:
StatusCodeURLRequestBodyResponseBody- S3 error metadata such as
RequestId,VersionId,Resource, andErrorCodewhen available
Automated Testing
The repository now uses Touchstone so the same shared descriptors can run through multiple hosts:
src/Test.Shared: shared Touchstone descriptors and test configurationsrc/Test.Automated: console runner usingTouchstone.Clisrc/Test.Xunit: xUnit adapter hostsrc/Test.Nunit: NUnit adapter host
Run the Console Runner
dotnet run --framework net8.0 --project src/Test.Automated -- -b my-bucket -a ACCESS_KEY -s SECRET_KEY
Optional arguments:
--endpoint <host>--port <port>--region <region>--http--https--path-style--virtual-hosted--verbose--skip-cleanup--skip-write-tests--results <path>
Run xUnit and NUnit
dotnet test src/Test.Xunit/Test.Xunit.csproj
dotnet test src/Test.Nunit/Test.Nunit.csproj
These runners read the same configuration from environment variables:
S3LITE_TEST_ENDPOINTS3LITE_TEST_PORTS3LITE_TEST_REGIONS3LITE_TEST_ACCESS_KEYS3LITE_TEST_SECRET_KEYS3LITE_TEST_BUCKETS3LITE_TEST_PROTOCOLS3LITE_TEST_REQUEST_STYLES3LITE_TEST_VERBOSES3LITE_TEST_SKIP_CLEANUPS3LITE_TEST_SKIP_WRITE_TESTS
Example Projects
src/Test.S3: interactive AWS S3 examplesrc/Test.S3Compatible: interactive S3-compatible examplesrc/Test.Script: script-style object hierarchy walkthroughsrc/Test.LargeEnumeration: large-listing exercise
Feedback and Enhancements
Encounter an issue or have an enhancement request? Please open an issue or start a discussion in the repository.
Version History
See CHANGELOG.md for release details.