Semantics
How LocalS3 behaves where a client can tell the difference: which requests it rejects, how conditional requests and versioning work, which entity tags it answers, and when change events are delivered.
LocalS3 aims to reject what Amazon S3 rejects, so a test that passes against LocalS3 doesn't pass with a request that Amazon S3 would refuse.
- Stored, not applied
- Request validation
- Conditional requests
- Range requests
- Versioning
- Default checksums
- Entity tags of multipart uploads
- Browser form uploads (POST Object)
- CORS
- Access control lists
- Lifecycle configuration
- Object Lock
- Storage classes and restores
- Appends and renames
- S3 Express One Zone directory buckets
- Server-side encryption with S3 managed and KMS keys (SSE-S3, SSE-KMS)
- Server-side encryption with customer-provided keys (SSE-C)
- The KMS endpoint
- Change events
Stored, not applied
Many configurations are accepted, stored and read back as they were put, so that code that sets them on the way to something else, e.g. Terraform, CDK or an application that configures its bucket when it starts, runs against LocalS3. What they would do on Amazon S3 doesn't happen. This table gathers them, so you can tell at a glance whether a test relies on one of them; such a test has to run against Amazon S3.
| API | What LocalS3 does | What differs from Amazon S3 |
|---|---|---|
PutBucketLifecycleConfiguration | Stored; applied when a test asks, with POST /_admin/lifecycle or LocalS3#applyLifecycle, or on a schedule that is off by default | Amazon S3 applies the rules by itself, once a day. See lifecycle configuration |
PutBucketNotificationConfiguration | Stored and returned as put; the ARNs aren't checked | No event reaches SNS, SQS, Lambda or EventBridge; use a change listener instead |
PutBucketAcl, PutObjectAcl, x-amz-acl, x-amz-grant-* | Stored and returned | Not enforced against signed requests; only a public READ opens a bucket to anonymous website reads. See access control lists |
PutBucketPolicy, PutPublicAccessBlock | Stored and returned | Not enforced against signed requests; only an Allow of s3:GetObject to * opens a bucket to anonymous website reads, and conditions aren't evaluated. GetBucketPolicyStatus answers IsPublic for any policy, unless BlockPublicPolicy or RestrictPublicBuckets is set |
PutBucketOwnershipControls | Stored and returned | ACLs keep working under BucketOwnerEnforced |
PutBucketAbac | Stored and returned; Disabled until put | No access is decided by the tags of a bucket |
PutBucketAccelerateConfiguration | Stored and returned | Nothing is accelerated |
PutBucketLogging | Stored and returned | No access log is written |
PutBucketRequestPayment | Stored and returned | Nothing is billed to the requester |
PutBucketReplication | Stored and returned | Nothing is replicated |
PutBucketAnalyticsConfiguration, PutBucketIntelligentTieringConfiguration, PutBucketInventoryConfiguration, PutBucketMetricsConfiguration | Stored and returned, by id | No analysis runs, no object changes tier, no inventory report is written, no CloudWatch metric is published |
x-amz-storage-class | Stored and answered by HeadObject, GetObject and the listings | Every object is kept and read the same way: a GLACIER or DEEP_ARCHIVE object is readable without a restore. See storage classes and restores |
RestoreObject | The restore completes at once | Tier is ignored; Amazon S3 takes minutes to hours |
x-amz-server-side-encryption*, PutBucketEncryption | Stored and answered like Amazon S3; the default encryption of a bucket applies to the headers | Nothing is encrypted, and the KMS key ID isn't resolved. See SSE-S3, SSE-KMS |
x-amz-server-side-encryption-customer-* (SSE-C) | Validated; the MD5 of the key is stored and checked on reads | Nothing is encrypted, and HTTPS isn't required. See SSE-C |
KMS Encrypt, GenerateDataKey, … | A faithful round trip, bound to the key ID and context | No key is stored and nothing is secret. See the KMS endpoint |
x-amz-expected-bucket-owner, x-amz-source-expected-bucket-owner | Ignored | Never 403 AccessDenied: LocalS3 has one account, which owns every bucket |
POST Object fields acl, x-amz-storage-class, server-side encryption | Like the headers of PutObject above; they still have to be named by the policy | As above |
S3 Vectors PutVectorBucketPolicy | Stored and returned | Not enforced |
| S3 Tables encryption, storage class, resource policies, maintenance, metrics, replication, record expiration | Stored and read back | Nothing is encrypted or tiered, no policy is enforced, no job runs: GetTableMaintenanceJobStatus answers Not_Yet_Run. See the S3 Tables API |
The document of a stored configuration must be well-formed XML whose root element is the one of its configuration,
otherwise 400 MalformedXML; its contents aren't checked, since nothing reads them. Deleting a configuration that a
bucket doesn't have succeeds, except for the ones named by id, which answer 404 NoSuchConfiguration.
An operation that LocalS3 doesn't have at all answers 501 NotImplemented rather than pretending to succeed, and is
counted under notImplemented by GET /_admin/stats; see apis.md.
The Tags of a CreateBucketConfiguration become the tagging of the new bucket, which GetBucketTagging answers,
like Amazon S3. They're dropped when a re-creation of a bucket in us-east-1 answers 200 and leaves the bucket as it was.
Request validation
- Bucket names must follow the naming rules
of general purpose buckets, e.g. 3 to 63 lowercase letters, numbers, periods and hyphens; otherwise
CreateBucketfails withInvalidBucketName. Buckets loaded from an existing data path stay accessible. - Every part of a multipart upload except the last one must be at least 5 MiB, the
minimum part size of Amazon S3; otherwise
CompleteMultipartUploadfails withEntityTooSmall. An upload with a single part may be of any size. - An object key must be at most 1024 bytes in UTF-8, and the user-defined metadata of an object at most 2 KB, the
UTF-8 encodings of the names, without their
x-amz-meta-prefix, and of the values summed: the limits of Amazon S3. OtherwisePutObject,CopyObject,CreateMultipartUpload,POST ObjectandRenameObjectfail with400 KeyTooLongErroror400 MetadataTooLarge. The key of a browser form upload is checked once${filename}is replaced. Objects of an existing data path stay readable whatever their keys and metadata.ObjectLimitsIntegrationTestchecks these answers, and runs the same scenarios against Amazon S3 with./gradlew :local-s3-integration-test:realS3Test. CompleteMultipartUploadchecksx-amz-mp-object-size, like Amazon S3: a size that isn't the sum of the parts fails with400 InvalidRequest, and one that isn't a non-negative number with400 InvalidArgument. The upload is kept, so it can be completed again with the right parts or aborted.- A request body is limited to 5 GiB by default (
netty(netty -> netty.maxRequestBodySize(...))), and its header section to 16 KiB (maxRequestHeaderSize). A body whose declaredContent-Lengthis already too large is rejected withEntityTooLargebefore100 Continueis sent, so the client never uploads it. - A single
PutObjectorUploadPartmay send up to 5 GiB, like Amazon S3 allows. A body larger than 2 GiB is kept in its temporary file and read from there, since no memory-mapped buffer holds it; a browser form upload (POST Object) larger than 2 GiB is rejected withEntityTooLarge. - If credentials are configured, the signature of a request with a body is verified before the body is received, so the body of a request that fails anyway is neither uploaded nor buffered.
- If credentials are configured, the time of a signed request (
x-amz-dateorDate) may differ from the clock of the service by 15 minutes at most, like Amazon S3 allows; otherwise403 RequestTimeTooSkewed, whose message names the request time and the server time. The clock of a Docker Desktop, WSL2 or Colima VM often drifts after the host sleeps, so the tolerance is configurable:s3Api(s3 -> s3.allowedClockSkew(Duration.ofHours(1))),LOCAL_S3_ALLOWED_CLOCK_SKEW=1h(--allowed-clock-skew 1h) orlocal-s3.allowed-clock-skew=1h.0turns the time check off, so that only the signature itself is verified, and a presigned URL doesn't expire either: itsX-Amz-Expiresisn't checked. The Docker images set0by default, so a test that an expired presigned URL is answered403runs against a container started with a duration, e.g.LOCAL_S3_ALLOWED_CLOCK_SKEW=15m. PutObjectandUploadPartaccept a body sent withTransfer-Encoding: chunkedand noContent-Length, i.e. a stream whose length the client didn't know when it started, e.g.curl -T -, a streamingfetchof Node.js or Deno, or a lightweight S3 library that streams. Amazon S3 answers such a request with411 MissingContentLength; LocalS3 receives the body whole and stores it with the number of bytes received as its length, so that such a client works locally.s3Api(s3 -> s3.acceptChunkedUploads(false)),@LocalS3(acceptChunkedUploads = false),LOCAL_S3_ACCEPT_CHUNKED_UPLOADS=falseorlocal-s3.accept-chunked-uploads=falseanswer411like Amazon S3, e.g. to test that a client declares the length of what it uploads. Theaws-chunkedbodies of the AWS SDKs declare their length inx-amz-decoded-content-lengthand aren't affected.- The tags of an object, of
PutObjectTagging,x-amz-taggingor a copy, are within the limits of Amazon S3: at most 10, each with a unique key of 1 to 128 characters and a value of at most 256; otherwise400 InvalidTag, and the object keeps the tags it has. - A
Content-MD5ofPutObjectorUploadPartthat isn't the base64 of an MD5 digest, e.g. an empty one, answers400 InvalidDigestbefore the content is read; one that doesn't match the content answers400 BadDigest, and a rejected part isn't stored. DeleteObjectstakes 1 to 1000 objects, otherwise400 MalformedXML. An object that can't be deleted is reported as an<Error>of its own, with its S3 error, and the other objects are still deleted. An unexpected failure, e.g. an I/O error of the storage, fails the whole request with500 InternalErrorand deletes none of the objects of aPERSISTENCEservice.x-amz-expected-bucket-ownerandx-amz-source-expected-bucket-ownerare accepted and ignored: LocalS3 has one account, which owns every bucket, so a request never fails with403 AccessDeniedfor naming another owner. A test that relies on that check must run against Amazon S3.
Conditional requests
GetObject and HeadObject evaluate the If-Match, If-None-Match, If-Modified-Since and
If-Unmodified-Since headers, in the order that
RFC 9110 defines: a read of an object that the
client already holds answers 304 Not Modified, and one whose If-Match or If-Unmodified-Since doesn't
hold answers 412 Precondition Failed.
PutObject, CopyObject and CompleteMultipartUpload evaluate the two entity tag headers as a
conditional write, the
way Amazon S3 does, against the current version of the destination key:
If-None-Match: *stores the object only if the key holds none, otherwise412 Precondition Failed;If-Match: "<etag>"stores it only if the key holds the object with that entity tag, otherwise412 Precondition Failed, or404 NoSuchKeyif the key holds no object at all.
A key whose current version is a delete marker holds no object. A CompleteMultipartUpload whose condition fails
keeps the upload and its parts, so it can be completed again or aborted.
DeleteObject and DeleteObjects evaluate a
conditional delete against the
current version of the object, whatever version the request deletes:
If-Match: "<etag>", or theETagof an object ofDeleteObjects, deletes only the object with that entity tag, andIf-Match: *only an object that exists: otherwise412 Precondition Failed(also if the current version is a delete marker), or404 NoSuchKeyif the key holds no version at all;x-amz-if-match-sizeandx-amz-if-match-last-modified-time, or theSizeandLastModifiedTimeof an object ofDeleteObjects, delete only an object of that size, or last modified in that second: otherwise412 Precondition Failed. A key that holds no object satisfies them.
DeleteObjects reports an object whose condition fails as an <Error> of its own and deletes the others.
CopyObject and UploadPartCopy also evaluate x-amz-copy-source-if-match, x-amz-copy-source-if-none-match,
x-amz-copy-source-if-modified-since and x-amz-copy-source-if-unmodified-since against the source object, in the
order of a read; a condition that doesn't hold answers 412 Precondition Failed, never 304 Not Modified.
Every write and delete condition is evaluated under the write lock of the bucket that the object is changed in, so the request is atomic: of the requests that race for a key, exactly one wins. Code that builds a lock or an optimistic update on that, e.g. the S3 commit protocols of Delta Lake and Iceberg, is exercised rather than silently losing its protection. See architecture.md for the lock.
Range requests
GetObject and HeadObject serve one byte range of the Range header, answered 206 Partial Content with a
Content-Range:
Range | Object of 10 bytes | Empty object |
|---|---|---|
bytes=0-, bytes=8-100, bytes=-3, bytes=-20 | 206, clipped to the object, e.g. bytes 8-9/10 | 416 InvalidRange |
bytes=10-, bytes=100-200, bytes=-0 | 416 InvalidRange | 416 InvalidRange |
bytes=5-2, bytes=0-1,4-5, bytes=abc | 200, the whole object | 200, the empty object |
No byte of an empty object can be read, so any range of it is unsatisfiable, bytes=0- included: a reader that probes
an empty object, e.g. a _SUCCESS marker, with a range gets 416, and reads it without one. A header that isn't a
single valid byte range is ignored, as RFC 9110 allows. A position too large for a 64-bit number, e.g.
bytes=0-99999999999999999999, is taken as beyond the end of the object: clipped as an end, 416 as a start. RangeEdgeCaseIntegrationTest checks these answers, and runs
the same scenarios against Amazon S3 with ./gradlew :local-s3-integration-test:realS3Test and the credentials of an
AWS account.
If-Range makes the range conditional, as RFC 9110
defines it: the range is served only if the object is still the one the client read it from, otherwise the whole
object is answered 200, so a resumed download, e.g. of a browser, never splices an old and a new object together.
If-Range | Object matches | Object doesn't match |
|---|---|---|
an entity tag, e.g. "9a0364b9e99bb480dd25e1f0284c8555" | 206, the range | 200, the whole object |
an HTTP date, compared with the second of Last-Modified | 206, the range | 200, the whole object |
a weak entity tag, W/"...", or a value that is neither | – | 200, the whole object |
It is evaluated after the other conditions of a read, so a 304 or a 412 still wins, and it is ignored without a
Range, e.g. with a partNumber. HeadObject evaluates it like GetObject.
Reading a part
GetObject and HeadObject with partNumber read one part of an object, answered 206 Partial Content with a
Content-Range and x-amz-mp-parts-count:
- The parts are counted from 1 in the order of the content, whatever part numbers they were uploaded with, so a client
that reads parts 1 to
x-amz-mp-parts-countreads the whole object. - An object that wasn't uploaded in parts has a single part, all of its content; an empty part has no
Content-Range. - A part number beyond the parts answers
400 InvalidPartNumber;partNumbertogether withRangeanswers400 InvalidRequest. - The checksum answered is the one of the part, of the algorithm of the object, with the checksum type of the object,
e.g.
COMPOSITE. A read of aRangeanswers no checksum, since the checksum is of the whole content.
Versioning
A bucket is in one of three states, as in Amazon S3: never versioned, versioning enabled, or versioning suspended.
The version that an operation reports in x-amz-version-id follows from it:
| The object was stored… | Reported version |
|---|---|
| in a bucket that was never versioned | none; the header is left out |
| before versioning was enabled or suspended | null, a value that is sent |
| while versioning was suspended | null, a value that is sent |
| while versioning was enabled | the version ID it was given |
The write that stores the null version, i.e. a PutObject, CopyObject, POST Object or CompleteMultipartUpload
to a bucket whose versioning is suspended, answers no x-amz-version-id, like Amazon S3; reading, tagging or deleting
that version reports null.
PutBucketVersioningtakes aStatusofEnabledorSuspended, and aMfaDeleteofEnabledorDisabled; any other value is400 MalformedXML. A configuration without aStatusleaves the versioning of the bucket as it is, so a bucket that was never versioned stays so.MfaDeleteis stored and answered back byGetBucketVersioning, so that a tool that compares the configuration it applies, e.g. Terraform, sees no change, but no MFA device is ever asked for. Versioning can't be suspended once Object Lock is configured (409 InvalidBucketState).- A bucket holds at most one null version of a key. A write while versioning is suspended, or before it was ever enabled, replaces it and becomes the current version; the versions stored while versioning was enabled are kept.
- A
DeleteObjectwithout a version ID adds a delete marker. In a bucket whose versioning is enabled, the marker gets a version ID of its own and the earlier versions are kept; otherwise it is the null version, and replaces the earlier one. - A
DeleteObjectwith a version ID removes that version for good, a delete marker included, which it answers withx-amz-delete-marker: true. Like Amazon S3, it is idempotent: it succeeds if the version, or the key, is already gone, so that the retries and the concurrent cleanups of Iceberg, Delta Lake or DuckLake don't fail. Its conditions, e.g.If-Match, still fail on a key that holds no version, see conditional requests. Unlike a read, a delete doesn't fail on a malformed version ID either; it deletes nothing. GetObject,HeadObject,CopyObject(throughversionIdof the source),GetObjectAttributes,RestoreObjectand the tagging, ACL, retention and legal hold operations address a version withversionId; a conditional read of a version is evaluated against that version. A version ID that LocalS3 couldn't have given, i.e. neithernullnor a number, is400 InvalidArgument(Invalid version id specified), as is any version ID butnullfor a bucket that was never versioned; a well-formed one that names no version is404 NoSuchVersion.- A key whose current version is a delete marker holds no object:
GetObjectandHeadObjectwithout a version ID answer404 NoSuchKey, withx-amz-delete-marker: trueand the version ID of the marker. Naming the marker withversionIdanswers405 MethodNotAllowed, withx-amz-delete-marker: trueandLast-Modified.ListObjectsandListObjectsV2leave such a key out. ListObjectVersionslists the versions and delete markers of the keys, newest first, withnullfor the null version. Akey-markeralone lists the keys after it; with aversion-id-marker, the listing goes on with the versions of that key after the version, and aversion-id-markerofnullgoes on after the null version. The markers needn't name a version that exists, so a client that deletes each page before it asks for the next one, e.g. to empty a bucket, pages through all of them. Unlike Amazon S3, a page that ends with the null version answers aNextVersionIdMarkerthat is the null version's internal ID rather thannull, so that the next page goes on from its place even if the null version is deleted or replaced in between, without listing a version twice. The ID is only a marker; it addresses no version. If the null version that anullmarker names is gone, every version left of its key is listed, so that none is skipped. An empty marker is no marker. Aversion-id-markerthat is neithernullnor a version ID that LocalS3 could have given, e.g. one from another S3 implementation, answers400 InvalidArgument, like Amazon S3, rather than skipping the versions of its key. A page is truncated only if something is left after it.- Noncurrent versions and delete markers are never removed by themselves: a
NoncurrentVersionExpirationorExpiredObjectDeleteMarkerrule removes them when a test applies the lifecycle rules.
Default checksums
Like Amazon S3 since December 2024, every object stored without a checksum gets a CRC64NVME checksum of type
FULL_OBJECT, computed while the content is stored, in the same pass as its MD5. Uploads by DuckDB, curl or older SDKs
thus answer x-amz-checksum-crc64nvme to a GetObject or HeadObject with x-amz-checksum-mode: ENABLED, and
ChecksumAlgorithm in ListObjectsV2, like the objects a client stored with a checksum.
PutObject, a browser form upload, an append to an object without a checksum and a copy of a source without one get the default. An object stored with a checksum of another algorithm keeps that one.- A multipart upload created without
x-amz-checksum-algorithmgives the object aFULL_OBJECTCRC64NVMEchecksum, combined from the CRCs that are computed while its parts are stored, so completing it doesn't read the parts again. Its parts answer no checksum, inUploadPart,UploadPartCopyorListParts, since the upload has no algorithm; ax-amz-checksum-crc64nvmein theCompleteMultipartUploadrequest is verified against the object. - An object stored before LocalS3 computed the default keeps having no checksum.
Object attributes
GetObjectAttributes answers only the attributes that x-amz-object-attributes names, leaving the others out
entirely, like Amazon S3. Its ETag is unquoted, unlike every other answer, and Checksum is left out for an object
stored without one. ObjectParts is answered for an object uploaded in parts, paged by x-amz-max-parts (at most, and
by default, 1000) and x-amz-part-number-marker; a part carries its checksum if it is of the algorithm of the object.
A header that isn't a non-negative number answers 400 InvalidArgument.
Copies
x-amz-copy-sourceofCopyObjectandUploadPartCopy, andx-amz-rename-sourceofRenameObject, are percent-decoded as a URL path: a+is a literal plus sign, not a space, so/bucket/a+b.txtand/bucket/a%2Bb.txtname the same key. A space is written as%20.- The source version of
x-amz-copy-sourceis the query parameter named exactlyversionId, e.g./bucket/a.txt?versionId=123; any other parameter,versionIdFoosay, is ignored, and so is an emptyversionId=, which copies the current version. CopyObjectcopies the metadata, the content type and the tags of the source, unlessx-amz-metadata-directiveorx-amz-tagging-directiveisREPLACE, which takes those of the request. The storage class, the website redirect location, the Object Lock settings and the encryption of the source are never copied: the copy has the ones of the request, like Amazon S3.- A copy of the current version of an object onto itself that changes nothing answers
400 InvalidRequest, like Amazon S3. Replacing the metadata or the tags, or setting the storage class, the encryption or the checksum algorithm, is a change; a copy of an older version restores that version. - A copy gets a checksum of the algorithm that the request names, or else of the algorithm of the source, computed from
the content. A
COMPOSITEchecksum of a source uploaded in parts becomes the full object checksum of the copy, which isn't stored in parts.
Entity tags of multipart uploads
A part gets the MD5 of its content as its entity tag, like Amazon S3; an ETag header of the UploadPart request
is ignored.
The object of a completed multipart upload gets the entity tag of Amazon S3: the MD5 of the concatenated MD5 digests
of its parts, followed by -<number of parts>. Before 2.5 LocalS3 answered the MD5 of the whole content instead;
s3Api(s3 -> s3.compositeMultipartEtags(false)), @LocalS3(compositeMultipartEtags = false) or
LOCAL_S3_COMPOSITE_MULTIPART_ETAGS=false bring that back for tests that depend on it.
Completing multipart uploads
CreateMultipartUploadtakesx-amz-checksum-typeonly withx-amz-checksum-algorithm, and only a type that the algorithm has, otherwise400 InvalidRequest; without a type, the algorithm's default, e.g.COMPOSITEfor SHA-256. A part uploaded without a checksum, e.g. byUploadPartCopy, gets one of the algorithm of its upload, and a part with a checksum of another algorithm answers400 InvalidRequest.UploadPartCopyanswers400 InvalidCopySourceRangefor anx-amz-copy-source-rangethat goes beyond the end of the source, which a read would clamp.- Every part that
CompleteMultipartUploadnames must have been uploaded, in ascending part numbers (InvalidPartOrder), with the entity tag that its upload answered (InvalidPart). The part sizes, the conditions andx-amz-mp-object-sizeare checked as described above; an upload that fails any of them is kept with its parts, so it can be completed again or aborted. - The parts aren't concatenated: the object references the stored parts, so completing an upload neither copies its content nor takes its space twice. The parts that the request doesn't name are deleted.
- An upload created with a checksum algorithm gives the object a checksum of that algorithm and type: a
COMPOSITEchecksum is the checksum of the checksums of the parts, with or without the-<parts>suffix in the request; aFULL_OBJECTone is combined from the CRCs of the parts. A checksum of a part in the request that doesn't match the part answersInvalidPart, a checksum of the object that doesn't match answersBadDigest, and a checksum type or algorithm other than the one of the upload answers400 InvalidRequest. - A
CompleteMultipartUploadthat is retried after the upload was completed, e.g. after a timeout, gets the answer of the completion again, without a change, for as long as the key holds the version that the upload stored; after that version is overwritten without versioning, or deleted, it answersNoSuchUpload, as Amazon S3 does. Only a retry with the part numbers that completed the upload, and their entity tags if it sends them, is answered so; a request with other parts answersNoSuchUpload. AbortMultipartUploadof an upload that doesn't exist, or was already aborted or completed, answers404 NoSuchUpload, like Amazon S3.ListPartsanswers400 InvalidArgumentfor amax-partsorpart-number-markerthat isn't a non-negative integer.max-parts=0answers an empty page that isn't truncated, so that a client paging by marker stops, and a marker beyond the last part an empty list, like Amazon S3.ListMultipartUploadsanswers at most 1000 uploads: a largermax-uploadsis capped, and answered as 1000 inMaxUploads, rather than rejected, like Amazon S3; one that isn't a non-negative integer answers400 InvalidArgument.
Browser form uploads (POST Object)
A web page can upload a file straight to a bucket with an HTML form, without the file passing through its backend:
the backend creates a policy, a base64-encoded JSON document that says what the form may upload and until when,
signs it, and puts both into hidden fields of the form. LocalS3 implements that flow, POST Object, so the
frontend can be developed and its policies debugged locally rather than against Amazon S3.
<form action="http://localhost:29090/uploads" method="post" enctype="multipart/form-data">
<input type="hidden" name="key" value="user/42/${filename}">
<input type="hidden" name="Content-Type" value="image/png">
<input type="hidden" name="success_action_status" value="201">
<input type="hidden" name="x-amz-algorithm" value="AWS4-HMAC-SHA256">
<input type="hidden" name="x-amz-credential" value="access-key-id/20300101/us-east-1/s3/aws4_request">
<input type="hidden" name="x-amz-date" value="20300101T000000Z">
<input type="hidden" name="policy" value="eyJleHBpcmF0aW9uIjoi...">
<input type="hidden" name="x-amz-signature" value="4b8c...">
<input type="file" name="file">
<button>Upload</button>
</form>
The fields are usually generated rather than written by hand, e.g. with createPresignedPost of
@aws-sdk/s3-presigned-post or generate_presigned_post of boto3, pointed at LocalS3 as their endpoint with
path-style addressing.
The form. The fields that precede file carry what a PutObject request carries in headers: key (required;
${filename} is replaced by the name of the selected file), Content-Type, Cache-Control, Content-Disposition,
Content-Encoding, Content-Language, Expires, x-amz-meta-* and tagging (a Tagging XML document). Field
names are case-insensitive, the fields after file are ignored, and the fields before it are limited to 20 KB. A form
of more than 2 GiB is rejected with EntityTooLarge. The object's change event is PostObject,
s3:ObjectCreated:Post.
Authentication. A service with credentials requires a policy and its signature, as fields rather than headers:
Signature Version 4 (x-amz-algorithm, x-amz-credential, x-amz-date, x-amz-signature), or the Signature
Version 2 that older upload libraries send (AWSAccessKeyId, signature). A service without credentials accepts
forms without a policy, but checks the policy of a form that carries one, so that its expiration and conditions
can be debugged without setting up credentials.
The policy. expiration is checked against the current time, and every condition against the form:
| Condition | Satisfied if |
|---|---|
{"field": "value"} or ["eq", "$field", "value"] | the field equals the value |
["starts-with", "$field", "prefix"] | the field starts with the prefix; "" accepts any value. Each value of a comma-separated Content-Type must |
["content-length-range", min, max] | the size of the file is between min and max bytes |
bucket is always the bucket that the form is posted to. Every field of the form must be named by a condition, except
policy, x-amz-signature, signature, AWSAccessKeyId, file and the fields that start with x-ignore-.
| Problem | Response |
|---|---|
The body isn't multipart/form-data | 400 RequestIsNotMultiPartContent |
The body is malformed, or has no file | 400 MalformedPOSTRequest, 400 IncorrectNumberOfFilesInPostRequest |
The fields before file exceed 20 KB | 400 MaxPostPreDataLengthExceededError |
No key | 400 InvalidArgument |
| No policy, on a service with credentials | 403 AccessDenied |
| A policy without a signature, on a service with credentials | 400 InvalidArgument: Bucket POST must contain a field named 'Signature'. |
| A wrong signature, or an unknown access key | 403 SignatureDoesNotMatch, 403 InvalidAccessKeyId |
The policy isn't base64, isn't JSON, lacks expiration or conditions, has an unknown condition, or a condition object that doesn't name exactly one field, e.g. {} | 400 InvalidPolicyDocument |
| The policy expired | 403 AccessDenied: Invalid according to Policy: Policy expired. |
| A condition fails | 403 AccessDenied: Invalid according to Policy: Policy Condition failed: ["starts-with","$key","user/42/"] |
| A field that no condition names | 403 AccessDenied: Invalid according to Policy: Extra input fields: x-amz-meta-note |
The file is outside of content-length-range | 400 EntityTooSmall, 400 EntityTooLarge |
The response. With success_action_redirect (or the older redirect), 303 See Other to that URL with
bucket, key and etag added to its query. Otherwise the success_action_status of the form: 200, 201 with
a PostResponse document (Location, Bucket, Key, ETag), or 204, the default. The ETag, Location and
x-amz-version-id headers carry the same. A page on another origin reads the response if the CORS configuration of
the bucket allows POST from it and exposes those headers.
Accepted but not applied, like the headers of PutObject: acl, x-amz-storage-class and the server-side
encryption fields. They still have to be named by the policy.
CORS
PutBucketCors, GetBucketCors and DeleteBucketCors manage the CORS configuration of a bucket, and LocalS3 applies
it like Amazon S3: a preflight OPTIONS request is answered by the first rule that allows its origin, its
Access-Control-Request-Method and all of its Access-Control-Request-Headers, and 403 if none does or the bucket
has no configuration; the response of an actual request carries the Access-Control-* headers of the rule that allows
it. Preflight requests are never signed, so they are answered without credentials.
The default CORS rule
On a developer's machine, configuring every bucket before a page in a browser can reach it is busywork: a single page
application that uploads with presigned URLs, DuckDB-WASM or a notebook reading data, a browser tool pointed at the
Iceberg catalog under /iceberg/v1. A service can therefore have a default CORS rule, which is off by default:
LocalS3.builder().defaultCors(cors -> cors.allowedOrigins("*")).build();
LOCAL_S3_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
local-s3.cors.allowed-origins=http://localhost:5173
It applies only where no bucket configuration does, so a bucket keeps the semantics of Amazon S3:
| Request | Answered by |
|---|---|
| A bucket with a CORS configuration of its own | That configuration alone; a request that it doesn't allow isn't allowed by the default rule either. |
| A bucket without one, or one that doesn't exist yet | The default rule. |
No bucket: ListBuckets, OPTIONS /, the Iceberg REST catalog, the S3 Tables API under /s3tables | The default rule. |
The console under /_admin/ui | Nothing: its preflight is always answered 403, so no other origin can send the X-LocalS3-Console header of its writes. |
Unless told otherwise, the rule allows GET, PUT, POST, DELETE and HEAD, every request header, and exposes the
response headers that the clients of a browser read, e.g. ETag for a multipart upload and x-amz-version-id; the
builder, the LOCAL_S3_CORS_* variables and the local-s3.cors.* properties narrow each of them, and set the max age
of a preflight response. With * the responses carry Access-Control-Allow-Origin: *, so a page can't send
credentials such as cookies; a listed origin is echoed back with Access-Control-Allow-Credentials: true.
The risk. An allowed origin lets its pages read and write the data of the service from the browser of anyone who
opens them, and * allows every web page, including one that a developer happens to visit while the service runs on
localhost. That is usually what a local service is for, but on a service that other machines reach, or that holds
data worth protecting, list the origins of your own pages rather than *, and require signed requests with
credentials.
Access control lists
PutBucketAcl and PutObjectAcl store an ACL, which GetBucketAcl and GetObjectAcl return. LocalS3 doesn't
enforce it against signed requests; the one place an ACL takes effect is
static website hosting, where a bucket that grants the AllUsers group READ answers
requests that carry no credentials. Like Amazon S3, a request gives the ACL in exactly one of three ways:
- A canned ACL, the
x-amz-aclheader, which grants the ownerFULL_CONTROL, andprivatenothing more;public-readandpublic-read-writegrant theAllUsersgroupREAD, andWRITE;authenticated-readgrants theAuthenticatedUsersgroupREAD;aws-exec-readgrants Amazon EC2READ.bucket-owner-readandbucket-owner-full-controlgrant the owner of the bucketREADorFULL_CONTROL, and only apply to objects;log-delivery-writegrants theLogDeliverygroupWRITEandREAD_ACP, and only applies to buckets. - Grant headers,
x-amz-grant-read,x-amz-grant-write,x-amz-grant-read-acp,x-amz-grant-write-acpandx-amz-grant-full-control, each a comma-separated list of grantees:id="...",uri="..."oremailAddress="...". - An
AccessControlPolicydocument in the body.
An ACL of headers keeps the owner that the bucket or object has. The rejected requests:
| Request | Error |
|---|---|
| A canned ACL together with grant headers | 400 InvalidRequest |
| A canned ACL or grant headers together with a body | 400 UnexpectedContent |
| No canned ACL, no grant headers and no body | 400 MissingSecurityHeader |
| An unknown canned ACL, one that doesn't apply to the resource, or a malformed grant header | 400 InvalidArgument |
A body that isn't an AccessControlPolicy document | 400 MalformedACLError |
Static website hosting
A bucket of static files is served as a website on the port of the S3 API, so a test can put an object with its S3 client and open the page in a browser without a second server:
http://localhost:29090/my-site/ -> my-site/index.html
http://localhost:29090/my-site/docs -> 302 to /my-site/docs/
http://localhost:29090/my-site/docs/ -> my-site/docs/index.html
http://localhost:29090/my-site/gone.html -> 404, the error document of the bucket or a generic error page
Virtual-hosted-style requests work too, e.g. http://my-site.localhost:29090/ or
http://my-site.127-0-0-1.nip.io:29090/.
The redirect of a directory keeps the query of the request, e.g. /my-site/docs?v=3 to /my-site/docs/?v=3, and its
Location is percent-encoded, so a directory whose name has a space, a %, a ?, a # or non-ASCII characters is
redirected to correctly.
Which requests are served as a website
A request is answered as a website request when all of these hold; anything else keeps the S3 semantics it had before:
- it is a
GETor aHEAD; - it carries no credentials: no
Authorizationheader, and noX-Amz-Algorithmof a presigned URL. So the requests of an S3 client, which are signed, are never affected, and a presigned URL still reads a private bucket; - it names no operation of the S3 API in its query, e.g.
?acl,?uploadsor?list-type=2. A query of the page itself, e.g.?v=3of a cache-busting link, is ignored, like Amazon S3 ignores it; - the bucket allows anonymous reads of the key, see below.
The one request such a bucket keeps the S3 semantics of is its root: with no index document to serve in its place,
GET /my-site is answered by ListObjects, so an unsigned listing of a public bucket still works.
Which buckets are public
A bucket allows an anonymous read when either of these makes it public, and the public access block of the bucket doesn't take it away:
-
its ACL grants the
AllUsersgroupREADorFULL_CONTROL, which the canned ACLpublic-readdoes:s3.putBucketAcl(b -> b.bucket("my-site").acl(BucketCannedACL.PUBLIC_READ)); -
its bucket policy allows
s3:GetObjectof the key to every principal:{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::my-site/*"}]}The resources are matched against the key, so a policy on
my-site/public/*publishes that prefix alone, and the actions may use wildcards, e.g.s3:Get*. An explicitDenywins over everyAllow. LocalS3 evaluates no conditions, so it errs on the private side both ways:Statement AllowDenyof every principal ("*")has a Condition, e.g.{"Bool": {"aws:SecureTransport": "false"}}skipped, publishes nothing applies, as if the condition held uses NotPrincipal,NotActionorNotResourceskipped, publishes nothing applies unless it excludes the read A
NotPrincipalexcludes the anonymous read only when it names every principal, aNotActionwhen it coverss3:GetObject, and aNotResourcewhen it covers the key.
IgnorePublicAcls takes the ACL away, BlockPublicPolicy the policy, and RestrictPublicBuckets either of them.
What a request is answered with must be public as well: an index document, an error document, or the directory that a
key is redirected to, e.g. public/docs/, that isn't public is treated as not there, so a policy that publishes
my-site/public/* answers public/missing with the generic error page rather than with a private error document.
Every other bucket stays private, and an unsigned request of it is rejected as before. To serve every bucket
without publishing it, which is meant for local development, set LOCAL_S3_WEBSITE_ALL_BUCKETS=true,
local-s3.website.all-buckets=true or LocalS3.builder().website(website -> website.allBuckets(true)). That also
lets an unsigned request read the objects of a private bucket, so it is off by default. LOCAL_S3_WEBSITE=false turns website hosting off
altogether.
The index and error documents
A bucket needs no configuration: a request for a directory is answered with index.html of that directory if the
bucket has one, and a key that isn't there with a generic error page. PutBucketWebsite configures a bucket of its
own, and LocalS3 applies it:
| Element | What it does |
|---|---|
IndexDocument/Suffix | The object that a request for a directory is answered with, e.g. home.html. |
ErrorDocument/Key | The object that a 404 is answered with, under the status of the failure rather than 200. It is always answered whole: Range and the conditional headers (If-Match, If-None-Match, If-Modified-Since, If-Unmodified-Since, If-Range) of the request apply to the page that wasn't found, not to the error document, so they are ignored rather than answered with a 206, 304 or 412. |
RedirectAllRequestsTo | Answers every request of the bucket with a 301 to that host, keeping the key. |
RoutingRules | Redirects the requests that a rule matches: Condition by KeyPrefixEquals, by HttpErrorCodeReturnedEquals, or by both; Redirect by HostName, Protocol, HttpRedirectCode, ReplaceKeyWith and ReplaceKeyPrefixWith. A condition without KeyPrefixEquals matches the empty prefix, so ReplaceKeyPrefixWith is prepended to the key, e.g. HttpErrorCodeReturnedEquals=404 with ReplaceKeyPrefixWith=report-404/ redirects missing.html to report-404/missing.html. The first rule that matches wins. An HttpRedirectCode that isn't a 3xx status is answered as 301. A redirect without a HostName stays on the host of the request, and on the bucket: a path-style request keeps its /<bucket> segment, e.g. /my-site/old/index.html to /my-site/docs/index.html. |
website(website -> website.indexDocument(...).errorDocument(...)), or the matching variables and properties,
change the documents of the buckets that have no configuration of their own.
Website redirect locations
An object stored with x-amz-website-redirect-location (PutObject, CopyObject, CreateMultipartUpload or a
POST Object form field) is answered by the website with a 301 to that location instead of its content, whether it
is addressed by its key or is the index document of a directory. The location is a path, e.g. /docs/new.html, or an
http:// or https:// URL; anything else, or one longer than 2 KB, answers 400 InvalidArgument. GetObject and
HeadObject of the S3 API answer the header and the content as usual. Like on Amazon S3, CopyObject doesn't copy
the location of its source; the copy has the one of the request, if any.
Content types
An object is served with the content type it was stored with. An object stored without one, which LocalS3 keeps as
binary/octet-stream, is served with the content type of its extension instead, e.g. text/html; charset=utf-8 for
.html and text/css; charset=utf-8 for .css, so that a directory of files copied into a bucket is a working site
rather than a set of downloads. A content type that was chosen is never overridden.
Lifecycle configuration
PutBucketLifecycleConfiguration, GetBucketLifecycleConfiguration and DeleteBucketLifecycle store, return and
delete the lifecycle configuration of a bucket, so that frameworks that set one when they start, e.g. to clean up
temporary files, work against LocalS3 instead of failing with 501 NotImplemented.
By default the configuration never takes effect by itself: nothing expires while a test runs. A test that exercises expiration asks LocalS3 to apply the configurations at a time of its choosing, which may be in the future, so that it doesn't wait for days to pass:
# Apply every bucket's configuration as if it were 31 days from now.
curl -s -X POST "http://localhost:29090/_admin/lifecycle?days=31"
# Apply one bucket's configuration at a given instant.
curl -s -X POST "http://localhost:29090/_admin/lifecycle?bucket=my-bucket&now=2030-01-01T00:00:00Z"
List<LifecycleActionAns> actions = localS3.applyLifecycle(Instant.now().plus(Duration.ofDays(31)));
// or, without the HTTP server: localS3.getS3Manager().objectService().applyLifecycle("my-bucket", instant)
The answer lists the actions taken, each with the bucket, the rule ID, the type, the key and the version or upload.
A service that runs for a long time, e.g. one embedded in an IDE or the store of the artifacts of an AI agent, can apply the configurations on a schedule instead, so that what a rule expires doesn't pile up. It is off by default, and is turned on with an interval:
| Where | Setting |
|---|---|
| Java API | LocalS3.builder().lifecycle(lifecycle -> lifecycle.applyEvery(Duration.ofHours(1))) |
| Executable jar, Docker | LOCAL_S3_LIFECYCLE_INTERVAL=1h / --lifecycle-interval 1h |
| Spring Boot starter | local-s3.lifecycle.interval=1h |
The interval is a number with the unit s, m, h or d, e.g. 30m, or an ISO 8601 duration, e.g. PT30M; 0
turns the schedule off. The configurations are applied at the current time once the service has started, and then
every interval after a run ends, on a daemon thread of the service; a run doesn't hold up the start of the service, and
a run that fails is logged and tried again an interval later. Stopping the service waits for a run in progress. A run
that took an action logs how many it took, and publishes its changes like any other application of the rules.
The enabled rules are applied the way Amazon S3 applies them:
| Rule | Action |
|---|---|
Expiration with Days or Date | The current version of an object that the filter selects expires: it is deleted in a bucket whose versioning was never enabled (OBJECT_EXPIRED), and gets a delete marker in a versioned bucket (DELETE_MARKER_CREATED). |
NoncurrentVersionExpiration | The versions and delete markers that have been noncurrent for NoncurrentDays are deleted, keeping the NewerNoncurrentVersions most recent noncurrent ones (NONCURRENT_VERSION_EXPIRED). A version that Object Lock protects is kept. |
Expiration with ExpiredObjectDeleteMarker | A delete marker that is the only version left of its object is removed (EXPIRED_DELETE_MARKER_REMOVED). |
AbortIncompleteMultipartUpload | The uploads of the keys that the prefix selects, created DaysAfterInitiation ago, are aborted (MULTIPART_UPLOAD_ABORTED). |
Transition, NoncurrentVersionTransition | Nothing: LocalS3 has one storage class. |
A filter selects by Prefix, Tag, ObjectSizeGreaterThan and ObjectSizeLessThan, alone or in an And; a rule
with the legacy Prefix outside a Filter selects by it. A number of days counts from the creation of the object, the
time a version became noncurrent, i.e. the creation of the next version, or the creation of the upload, and is rounded
up to the next midnight UTC, like Amazon S3 does. The changes are published with the operation LifecycleExpiration,
see change events.
The objects of a bucket are applied in batches of 1,000 keys, in key order, each batch under the lock of the bucket and persisted on its own, so that a run on a large bucket doesn't hold up the writes to it until the run ends. A run is therefore not atomic: a write between two batches is seen by the later batches, a run stops at the batch that finds the bucket deleted or its configuration removed, and the incomplete multipart uploads are aborted with the last batch.
What is checked is the structure that Amazon S3 checks, so a configuration that Amazon S3 rejects isn't accepted:
| Configuration | Answer |
|---|---|
Not well-formed XML, a document type, a root element other than LifecycleConfiguration, no Rule, or an element other than Rule in it | 400 MalformedXML |
A rule whose Status isn't Enabled or Disabled | 400 MalformedXML |
A rule without an action (Expiration, Transition, NoncurrentVersionExpiration, NoncurrentVersionTransition or AbortIncompleteMultipartUpload) | 400 InvalidRequest |
| More than 1000 rules | 400 InvalidRequest |
An ID longer than 255 characters, or the same ID in two rules | 400 InvalidArgument |
An x-amz-transition-default-minimum-object-size other than all_storage_classes_128K or varies_by_storage_class | 400 InvalidArgument |
A Date of an Expiration or a Transition that isn't an ISO 8601 date at midnight UTC, e.g. 20200101 | 400 InvalidArgument |
A rule with ExpiredObjectDeleteMarker set to true and a Tag in its Filter, which a delete marker never matches | 400 InvalidArgument |
The contents of the filters and actions aren't checked beyond that. The document is stored as it was put, except that a
rule without an ID gets a generated one, like on Amazon S3, and GetBucketLifecycleConfiguration returns it as is, with the x-amz-transition-default-minimum-object-size it
was put with, all_storage_classes_128K by default. A bucket without a configuration answers
404 NoSuchLifecycleConfiguration; deleting the configuration of such a bucket succeeds.
Object Lock
Object Lock protects object versions from being deleted, for testing compliance code, e.g. that a retention is set on what is stored, or that a delete is refused.
- A bucket gets Object Lock when it is created with
x-amz-bucket-object-lock-enabled: true, which enables its versioning too, or whenPutObjectLockConfigurationis sent to a bucket whose versioning is enabled; otherwise it answers409 InvalidBucketState. Object Lock can't be disabled, andPutBucketVersioningcan't suspend the versioning of such a bucket (409 InvalidBucketState).GetObjectLockConfigurationof a bucket without it answers404 ObjectLockConfigurationNotFoundError. - The configuration may have a default retention, a
Modewith eitherDays(at most 36500) orYears(at most 100), which every new version stored without a retention of its own gets. PutObject,CopyObjectandCreateMultipartUploadstore a version withx-amz-object-lock-mode,x-amz-object-lock-retain-until-date(both or neither, in the future) andx-amz-object-lock-legal-hold. They answer400 InvalidRequestfor a bucket without Object Lock. A copy doesn't copy the settings of its source.GetObjectandHeadObjectanswer the same headers.PutObjectRetention/GetObjectRetentionandPutObjectLegalHold/GetObjectLegalHoldaddress the current version, or the one ofversionId. A version whose retention or legal hold was never set answers404 NoSuchObjectLockConfiguration.- A retention that hasn't expired can be extended in the same mode; shortening, removing it or changing its mode answers
403 AccessDenied, unless it is inGOVERNANCEmode and the request sendsx-amz-bypass-governance-retention: true. Like in Amazon S3, that includes turning aGOVERNANCEretention into aCOMPLIANCEone. - Deleting a version for good, with
DeleteObjectorDeleteObjectsand aversionId, answers403 AccessDeniedwhile its legal hold is on, or its retention hasn't expired, unless the retention is inGOVERNANCEmode and the request bypasses it. A delete without a version ID still adds a delete marker, like in Amazon S3.
Unlike Amazon S3, LocalS3 doesn't require a Content-MD5 or checksum on the requests that store a version with Object
Lock settings.
Storage classes and restores
x-amz-storage-class is stored with an object and answered by HeadObject, GetObject and the listings, but every
object is kept and read the same way: a GLACIER or DEEP_ARCHIVE object is readable without a restore.
RestoreObject records a restore rather than performing one, so that code that restores cold data before reading it
runs against LocalS3:
- An object of the
GLACIERorDEEP_ARCHIVEstorage class is restored at once: the first restore answers202 Accepted, and one of an object whose restored copy hasn't expired answers200 OKand extends the copy. HeadObjectandGetObjectthen answerx-amz-restore: ongoing-request="false", expiry-date="...", where the expiry date is midnight UTC afterDaysdays, like Amazon S3 rounds it. After that date the header is gone, and the next restore answers202again.Daysis required; theTierand the other elements of theRestoreRequestare ignored.ListObjectsandListObjectsV2answer theRestoreStatusof an object with a restored copy that hasn't expired,IsRestoreInProgressfalseand itsRestoreExpiryDate, when the request asks for it withx-amz-optional-object-attributes: RestoreStatus, like Amazon S3; any other attribute answers400 InvalidArgument.- An object of any other storage class, including
GLACIER_IRandINTELLIGENT_TIERING, answers403 InvalidObjectState, and a delete marker405 MethodNotAllowed.
Appends and renames
Amazon S3 offers these two for the directory buckets of S3 Express One Zone. LocalS3 offers them for the buckets whose
versioning was never enabled, which directory buckets are like; a versioned bucket answers 400 InvalidRequest to a
rename.
- Append: a
PutObjectwithx-amz-write-offset-bytesappends its content to the object, if the offset is the size of the object, or is0for a key that holds no object, which creates it; otherwise it answers400 InvalidWriteOffset, as it does if another request changed the object while the content was being stored. The object keeps its content type, metadata and tags. Its entity tag is the MD5 of the whole content, and a checksum orContent-MD5of the request is verified against the appended bytes. The response carriesx-amz-object-size. - RenameObject:
PUT /bucket/new-key?renameObjectwithx-amz-rename-source: /bucket/old-keymoves the object, without copying its content, and replaces the object the new key holds.If-Match,If-None-Match,If-Modified-SinceandIf-Unmodified-Sinceare evaluated against the destination, and thex-amz-rename-source-if-*headers against the source; either answers412 PreconditionFailed. The source is in the same bucket, named as/bucket/keyor as the key alone.x-amz-client-tokenis accepted and ignored.
S3 Express One Zone directory buckets
A bucket may be named like a directory bucket, base-name--zone-id--x-s3, e.g. my-bucket--usw2-az1--x-s3, which is
otherwise a reserved suffix. An AWS SDK addresses such a bucket the S3 Express way, and LocalS3 answers it the same way
it answers any bucket, so that client code written for S3 Express One Zone runs against LocalS3 unchanged:
- CreateSession:
GET /bucket?sessionanswers session credentials, issued statelessly like the temporary credentials of the STS endpoint: they are valid for five minutes, across restarts, and the SDK renews them before they expire.x-amz-create-session-modemust beReadWriteorReadOnly, but aReadOnlysession may write too, and a session isn't bound to its bucket. Thex-amz-server-side-encryption*headers are echoed,AES256by default. The bucket needn't exist: with an endpoint override, the AWS SDK for Java creates a session even forCreateBucket, which Amazon S3 answers through its control endpoint without one. - Session credentials: the requests of the session are signed for the
s3expressservice, and carry the session token inx-amz-s3session-token, or inX-Amz-S3session-Tokenfor a presigned URL. LocalS3 acceptss3expresswherever it acceptss3. - By default the SDK addresses a directory bucket virtual-hosted style, e.g.
my-bucket--usw2-az1--x-s3.localhost:9090, so the host must resolve to LocalS3, which*.localhostdoes on most systems; withforcePathStyleit works as well. - The bucket behaves like a general purpose bucket otherwise: the
LocationandBucketof theCreateBucketConfigurationare ignored, and versioning, which directory buckets lack, isn't refused.
Server-side encryption with S3 managed and KMS keys (SSE-S3, SSE-KMS)
PutObject, POST Object, CopyObject and CreateMultipartUpload accept x-amz-server-side-encryption (AES256,
aws:kms or aws:kms:dsse), and for the KMS algorithms x-amz-server-side-encryption-aws-kms-key-id,
-context and -bucket-key-enabled. Nothing is encrypted, and the KMS endpoint is never called for it: LocalS3 stores what the
request names with the object version, or with the upload, so that client code that sets these headers, e.g. Iceberg
S3FileIO with s3.sse.type, sees the responses it sees against Amazon S3.
What LocalS3 proves is that your code sends the right headers and reads the right responses, and nothing beyond it. Don't use LocalS3 to verify encryption strength, key rotation, or that a KMS key policy allows an object to be read: there is no ciphertext to be strong, no key to rotate, and no policy to deny. Those belong against Amazon S3.
- The headers are validated: an unknown algorithm, a key ID or a context without a KMS algorithm, a context that isn't
a base64 encoded JSON object, a
bucket-key-enabledother thantrueorfalsewith a KMS algorithm, and an algorithm together with an SSE-C key all answer400 InvalidArgument. The KMS key ID isn't resolved or checked. PutObject,POST Object,CopyObject,CreateMultipartUpload,UploadPart,UploadPartCopy,CompleteMultipartUpload,GetObjectandHeadObjectanswer the algorithm, the key ID as the request named it, andbucket-key-enabled: trueif it was set; the context is only answered by the requests that store an object with it, like Amazon S3 does. A key ID is only answered if the request named one: LocalS3 doesn't make up the ARN of an AWS managed key.- The parts of a multipart upload get the encryption of the upload. A copy gets the encryption that the
CopyObjectrequest names, not the one of its source, and an append keeps the one of the object that it extends. - An object or an upload that is stored without the headers, and without an SSE-C key, gets the default encryption of
its bucket, the
ApplyServerSideEncryptionByDefaultof the first rule ofPutBucketEncryption; aKMSMasterKeyIDandBucketKeyEnabledonly count with a KMS algorithm. The configuration isn't validated when it is stored, and one that can't be read, or names an unknown algorithm, applies no encryption. A bucket without a configuration applies none either: LocalS3 doesn't answerAES256for every object like Amazon S3 does since January 2023.
Server-side encryption with customer-provided keys (SSE-C)
PutObject, CopyObject, CreateMultipartUpload, UploadPart, UploadPartCopy, GetObject, HeadObject and
GetObjectAttributes accept the x-amz-server-side-encryption-customer-algorithm, -customer-key and
-customer-key-MD5 headers, and the x-amz-copy-source-server-side-encryption-customer-* ones for the source of a
copy. Nothing is encrypted: LocalS3 stores the MD5 of the key, never the key, so that client code that uses SSE-C
runs as it does against Amazon S3. The boundary above holds here too: what LocalS3 checks is that the key you read an
object with is the key you wrote it with, not that the object was ever protected by it.
- The headers are validated like Amazon S3 validates them: all three,
AES256(400 InvalidEncryptionAlgorithmErrorotherwise), a base64 256-bit key, and its MD5 (400 InvalidArgumentotherwise). - The responses that store or serve such an object echo the algorithm and the MD5 of the key.
- Reading an object stored with a key, or uploading a part to an upload created with one, without the key answers
400 InvalidRequest, and with another key403 AccessDenied. Sending a key for an object stored without one answers400 InvalidRequest.
Unlike Amazon S3, LocalS3 doesn't require HTTPS for SSE-C requests.
The KMS endpoint
LocalS3 answers the KMS actions GenerateDataKey, GenerateDataKeyWithoutPlaintext, Encrypt, Decrypt,
DescribeKey and GenerateRandom on its own port, for the clients that encrypt objects themselves and call KMS to
wrap the data key, e.g. the Amazon S3 Encryption Client, and for the code that resolves a key with DescribeKey
before it sends x-amz-server-side-encryption: aws:kms. It is separate from the server-side encryption headers above,
which never reach it.
Nothing is kept secret. A ciphertext blob is the plaintext itself in a framed, base64 encoded envelope that anyone can unpack, and no key material exists. What LocalS3 does give is a faithful round trip, so a client that wraps a data key, stores the blob and unwraps it later reads its object back. A blob written by LocalS3 protects nothing; don't put one where a real one belongs.
The Amazon S3 Encryption Client runs over this endpoint end to end — GenerateDataKey, the client encrypting the
object, PutObject, GetObject, Decrypt — and S3EncryptionClientIntegrationTest keeps it that way. Such an object
really is encrypted, by the client, but under a key that LocalS3 hands out and wraps in the clear.
- The requests are AWS JSON 1.1, like KMS:
POST /withX-Amz-Target: TrentService.<action>, signed for thekmsservice, with the credentials of LocalS3 or with temporary credentials of its STS endpoint. The errors are the JSON errors of KMS, e.g.ValidationException, not an<Error>document of S3. - No keys are stored, so every key ID, alias or ARN is valid, and
DescribeKeydescribes it as an enabled symmetric key. A key ID that isn't an ARN is answered asarn:aws:kms:us-east-1:000000000000:key/<id>, an alias as.../alias/<name>. No key is created, deleted, rotated or disabled. - A blob is bound to its key ID and encryption context: a
Decryptwith another context answersInvalidCiphertextExceptionand one with anotherKeyIdanswersIncorrectKeyException, like KMS does, so a client that mixes them up is told rather than handed a plaintext. A blob that LocalS3 didn't write answersInvalidCiphertextExceptiontoo.KeyIdis optional forDecrypt, as it is for a symmetric key of KMS. GenerateDataKeyreturns a fresh random plaintext ofKeySpec(AES_256by default, orAES_128) or ofNumberOfBytes, so no two objects share a data key; naming both answersValidationException.Encrypttakes up to 4096 bytes, as KMS does. The onlyEncryptionAlgorithmisSYMMETRIC_DEFAULT: there are no asymmetric keys.- Because nothing is stored, a blob survives a restart and is read by any LocalS3, in memory and persistence mode alike, whatever its credentials.
The S3 Tables API
LocalS3 answers the Amazon S3 Tables API on its own port: table buckets, the namespaces and the tables in them, and the metadata locations that make a commit. Every table bucket is also an Iceberg REST catalog, and the two are views of one catalog rather than two copies of it.
- A request of this API is told from an Amazon S3 one by the
s3tablesservice in its credential scope, because the two share their paths, and the signature is then verified for that service. A client that signs nothing reaches the API under/s3tables. See reaching the API. - The tables of a table bucket live in an ordinary bucket of the same service,
<table-bucket>--table-s3, so an engine'sS3FileIOwrites its data files there and a test can read them with anS3Client. - A commit is guarded by the version token:
UpdateTableMetadataLocationwith a token that is no longer current is answered409 ConflictException, and a commit made over the Iceberg REST endpoint of the same table bucket draws a new token, so the two protocols commit against one another safely. - Encryption, storage class, resource policies, maintenance, metrics, replication and record expiration are stored and
read back, and nothing happens. Nothing is encrypted or tiered, no policy is enforced, and no compaction, replication
or expiration job ever runs —
GetTableMaintenanceJobStatusanswersNot_Yet_Runfor every job andGetTableReplicationStatusanswers no destination. What that buys is the code path under test, which usually sets a configuration on the way to doing something else, running through instead of failing on an unimplemented operation. - Every ARN is answered with the region and the account of LocalS3,
us-east-1and000000000000, and the account of an ARN a client sends is ignored rather than refused. - A namespace is one level and
ICEBERGis the only table format, as they are in Amazon S3 Tables. The full list of what differs is in data-tools.md.
Change events
A service can be given S3ChangeListeners, which receive an S3Change whenever a bucket or an object changes, e.g.
to trigger an indexer or to assert in a test that an upload happened. embedding.md
shows how to subscribe one. Several listeners may be subscribed; each receives every change.
Changes are published by the services of LocalS3 rather than by its HTTP handlers, so a change is delivered however
it is made: by a client of the HTTP API, or by the application itself through localS3.getS3Manager(), e.g.
getS3Manager().objectService().putObject(...). The buckets of buckets(...) / AWS_BUCKETS fire
BUCKET_CREATED too. A change is only delivered once it is persisted and the lock of its bucket is released,
so a listener never hears of a change that was rejected or failed, and may call LocalS3 again. A reset() doesn't
fire changes for the data it drops.
By default a listener runs on the thread that made the change, before the operation returns, so the time it takes
counts toward the latency of the request; a listener that takes longer than 1 second there is logged once as a WARN.
events(e -> e.executor(...)) delivers the changes on another executor instead.
Every change carries type(), bucketName() and operation(), the S3 operation that made it, e.g. PutObject,
CopyObject, CompleteMultipartUpload, DeleteObject, DeleteObjects or CreateBucket. s3EventName() names the
change like an
Amazon S3 event notification,
e.g. s3:ObjectCreated:Copy or s3:ObjectRemoved:DeleteMarkerCreated, and is null for the changes that Amazon S3
doesn't notify of.
| Change type | Operation | s3EventName() | Details |
|---|---|---|---|
BUCKET_CREATED, BUCKET_DELETED | CreateBucket, DeleteBucket | null | bucketName(), bucketRegion(); key() is null |
OBJECT_CREATED | PutObject, CopyObject, CompleteMultipartUpload, RenameObject | s3:ObjectCreated:Put, :Copy, :CompleteMultipartUpload | key(), size(), etag(), versionId(), contentType(), userMetadata() |
OBJECT_DELETED | DeleteObject, DeleteObjects, RenameObject (the old key) | s3:ObjectRemoved:Delete, :DeleteMarkerCreated | key(), versionId(), deleteMarker(); size(), etag() and contentType() are null, userMetadata() is empty |
OBJECT_DELETED | LifecycleExpiration | s3:LifecycleExpiration:Delete, :DeleteMarkerCreated | as above, for what an applied lifecycle rule expired |
OBJECT_TAGGING_PUT, OBJECT_TAGGING_DELETED | PutObjectTagging, DeleteObjectTagging | s3:ObjectTagging:Put, :Delete | key(), versionId(), size(), etag(), contentType(), userMetadata() of the version |
OBJECT_ACL_PUT | PutObjectAcl | s3:ObjectAcl:Put | key(), versionId(), size(), etag(), contentType(), userMetadata() of the version |
MULTIPART_UPLOAD_ABORTED | AbortMultipartUpload, LifecycleExpiration | null | key(), uploadId(); fired only if the upload existed |
Only a change of a bucket leaves key() null, which tells the two apart. versionId() is null if the bucket has
never been versioned.
contentType() and userMetadata() are those of the version as it was committed, so a listener can route an upload by
its type or its x-amz-meta-* without a HeadObject, which, with an asynchronous executor, may already read a later
version of the key. contentType() is the Content-Type the version was stored with, null if it was stored without
one (it is then served as binary/octet-stream). userMetadata() is the user-defined metadata of the version: for a
request of the HTTP API, the names of its x-amz-meta-* headers, without the prefix and in lower case, mapped to their
values. It is never null, and can't be modified.
Every change also carries eventTime() and sequencer(), a hexadecimal string of 16 digits that increases with every
change, so the later of two changes of a key has the greater sequencer, compared as strings. Two changes are equals
when they change the same thing in the same way: eventTime() and sequencer() are not compared, and neither are
contentType() and userMetadata(), which describe the version rather than the change, so a test can compare a
received change to S3Change.objectVersion(...) and the like.
Amazon S3 event notification JSON
toS3EventJson() maps a change to the
event notification that
Amazon S3 would send, {"Records":[{...}]} with eventVersion 2.1, so a test can assert on the structure that a
consumer of Amazon S3, e.g. a Lambda handler, reads, and a future destination such as a webhook only has to send it:
{"Records":[{"eventVersion":"2.1","eventSource":"aws:s3","awsRegion":"us-east-1",
"eventTime":"2026-09-25T08:30:00.123Z","eventName":"ObjectCreated:Put",
"userIdentity":{"principalId":"LocalS3"},"requestParameters":{"sourceIPAddress":"127.0.0.1"},
"responseElements":{"x-amz-request-id":"0199...","x-amz-id-2":"0199..."},
"s3":{"s3SchemaVersion":"1.0",
"bucket":{"name":"photos","ownerIdentity":{"principalId":"LocalS3"},"arn":"arn:aws:s3:::photos"},
"object":{"key":"2026/cat+1.jpg","size":5,"eTag":"...","versionId":"...","sequencer":"0199..."}}}]}
As in Amazon S3, eventName has no s3: prefix, the key is URL-encoded with spaces as +, and size, eTag and
versionId are left out when the change has none. The values LocalS3 doesn't know are fixed: the principals are
LocalS3, the source IP is 127.0.0.1, the request IDs are the sequencer, and awsRegion of an object change is
us-east-1. toS3EventJson() throws IllegalStateException for a change whose s3EventName() is null.
S3EventNotification has the building blocks: toRecord(change, configurationId) for one record as an ObjectNode,
and toJson(changes, configurationId) for a notification of several changes, which skips those Amazon S3 doesn't
notify of.
By default, the listeners run synchronously on the thread that made the change, so the change of a request is
delivered before the S3 response is sent. With an executor, events(events -> events.executor(...)), changes are
delivered asynchronously, so that slow listeners don't hold up request handling; a single-threaded executor keeps the
changes in order.
LocalS3 does not shut the executor down; that stays with the code that created it. Either way, an exception thrown by a listener is logged and never fails the S3 request, and a change that the executor rejects is dropped with a log entry.