Limitations

Everything that does not work the way S3 does, what happens instead, and what to do about it.

We publish this because you will find it anyway — ten minutes with any SDK is enough — and because a limitation you learn about on your first day is a design decision, while the same limitation on your first outage is a betrayal.

Each entry below is a single record. It renders here, on every integration page it affects, and in search. When one is fixed, it disappears from all of them at once.

Blockers

These stop a normal workflow. Read them before you plan a migration.

Blocker

Conditional reads return 400 instead of 304 or 412

You’ll see
A GET carrying If-None-Match, If-Match or If-Modified-Since returns 400 InvalidRequest. Expected behaviour is 304 Not Modified for a cache hit and 412 PreconditionFailed for a stale precondition.
Why
The conditional headers are not handled correctly on the read path. This is a defect, not a design decision.
Instead
Do not send conditional headers on GET. If you are putting a CDN in front of a bucket, cache on a fixed TTL rather than on revalidation, and version your object keys so a new key means new content. Conditional *writes* (If-None-Match on PUT) are separately unsupported.

Applies to AWS SDK for JavaScript v3, Browser uploads.

Blocker

Keys containing . or .. path segments are rejected

You’ll see
403 SignatureDoesNotMatch for a key with a dot segment in it, such as `./relative/../path.bin`, while every other key in the same run works.
Why
The URL is normalised somewhere between signing and verification, so the path that gets verified is not the path that was signed. This is a defect.
Instead
Normalise keys before uploading — collapse `.` and `..` segments yourself. This matters most when keys are derived from filesystem paths during a migration; a bulk copy will otherwise fail on a small, unpredictable subset of objects.

Applies to boto3, rclone.

Friction

These have a workaround, and the workaround is usually one line.

Friction

CORS is configured on the control plane, not with PutBucketCors

You’ll see
A browser upload fails at the preflight — the OPTIONS request is refused and the real request never runs. PutBucketCors returns 405 MethodNotAllowed.
Why
Bucket configuration is owned by the control plane. Each bucket is provisioned with CORS rules for the dashboard origin; your own origins have to be added there, not through the S3 API.
Instead
Add your origin in the dashboard's bucket settings, or with `PUT /v1/buckets/{bucket}/cors`. Read the current rules with `GET /v1/buckets/{bucket}/cors`. Do this *before* testing a browser upload — a missing rule looks exactly like a broken signature from the browser console.

Applies to Browser uploads.

Friction

Bucket-level configuration APIs are not offered

You’ll see
405 MethodNotAllowed on PutBucketPolicy, PutBucketLifecycleConfiguration, PutBucketWebsite, PutBucketReplication, PutBucketNotificationConfiguration, PutBucketTagging, PutPublicAccessBlock and their delete counterparts; the matching Get calls return 501 or are refused upstream.
Why
These configure infrastructure behaviour that PopCloud manages itself. Access control is expressed through credential scopes and the bucket's public flag rather than through bucket policies.
Instead
Use credential scopes for access control (permissions plus a bucket allowlist, optionally with an expiry), the bucket's public flag for anonymous reads, and the control plane for CORS. Tools that reconcile bucket configuration — Terraform's `aws_s3_bucket` in particular — must be limited to object-level resources.

Applies to AWS CLI, Django.

Friction

The control-plane API has no machine token

You’ll see
There is no API key to give a CI job or a Terraform provider that needs to create a bucket, mint a credential, or set CORS rules.
Why
The control plane authenticates people: an access token from `POST /v1/auth/login` plus a refresh cookie. Storage credentials (PCAK…) authenticate against the S3 endpoint only — they carry no control-plane authority.
Instead
For automation, log in from the job with a dedicated user's credentials, hold the access token for the run, and refresh if the run outlives it. Do not embed a human's long-lived session in shared CI. Bucket and credential creation is otherwise a one-time setup step you can do in the dashboard.
Friction

Buckets are not created over the S3 API

You’ll see
405 MethodNotAllowed from CreateBucket or DeleteBucket — `aws s3 mb`, `mc mb`, or a tool provisioning its own bucket on first run.
Why
Bucket lifecycle belongs to the control plane, which also allocates the storage account, the data-plane key and the CORS rules that come with it. Letting the edge create buckets would create half of one.
Instead
Create buckets in the dashboard, or with `POST /v1/buckets` on the control-plane API. Everything object-level then works normally against that bucket.

Applies to AWS CLI, Django, boto3, rclone, AWS SDK for JavaScript v3, AWS SDK for Go v2, AWS SDK for Java v2, AWS SDK for PHP, AWS SDK for Ruby.

Friction

Browser POST-policy uploads are not supported

You’ll see
403 AccessDenied when a browser submits a multipart/form-data POST to the bucket root, using a policy document and signature as form fields.
Why
The edge parses inbound authentication from the Authorization header and the query string. It never looks at form fields, so a POST policy carries no identity it can verify.
Instead
Use a presigned PUT — it is one URL, it supports Content-Type and size limits through the signature, and every modern uploader supports it. For large browser uploads use presigned multipart via `POST /v1/files/multipart` and `POST /v1/files/multipart/{upload_id}/sign`.

Applies to Browser uploads.

Friction

Public bucket names currently expose object listings

You’ll see
An anonymous ListObjectsV2 request to a public bucket returns its object keys, even though the caller was only given one object's URL.
Why
Public visibility currently bypasses authentication for every bucket GET and HEAD, including bucket-level listing requests as well as object reads.
Instead
Keep the bucket private when object names or the bucket inventory are sensitive. Use short-lived presigned GET URLs for private downloads. Use a public bucket only for intentionally public assets until anonymous listing is restricted to signed callers.
Friction

Per-chunk signed streaming uploads are refused

You’ll see
501 NotImplemented on an upload whose x-amz-content-sha256 is STREAMING-AWS4-HMAC-SHA256-PAYLOAD.
Why
That encoding signs every chunk of the body separately, which a re-signing proxy cannot forward without buffering the whole object. The platform streams instead, and never holds a body in memory.
Instead
Nothing to do on a current SDK — the modern default is STREAMING-UNSIGNED-PAYLOAD-TRAILER, which works, checksum trailer and all. Only older SDK versions and explicitly-configured chunked signing hit this. If you do, set the payload signing option off (`AWS_S3_DISABLE_CHUNKED_ENCODING`, `chunkedEncodingEnabled=false`, or the equivalent) and the SDK falls back to a supported encoding.

Applies to AWS SDK for Java v2.

Worth knowing

Unlikely to affect you, cheap to know about.

Worth knowing

The bucket listing omits some elements strict parsers expect

You’ll see
A tool fails to parse the bucket list, shows no owner, or sorts every bucket as if it were created just now.
Why
The bucket list is synthesized at the edge from the organisation's own buckets, rather than proxied. It currently ships without an XML namespace declaration or an Owner element, and stamps CreationDate with the time of the request.
Instead
Address buckets by name rather than discovering them from the listing, and do not rely on CreationDate. Every other listing operation — ListObjects, ListObjectsV2, ListObjectVersions, ListParts, ListMultipartUploads — is proxied and complete.

Applies to rclone.

Worth knowing

Customer-managed encryption keys (SSE-C, SSE-KMS) are not supported

You’ll see
400 InvalidArgument when a request carries x-amz-server-side-encryption-customer-* headers, or asks for aws:kms.
Why
There is no key-management service behind this endpoint, and customer-supplied keys are not accepted upstream.
Instead
Server-side encryption with a provider-managed key (AES256) works and can be set as a bucket default. If you need to hold the keys yourself, encrypt client-side before upload — the object is opaque bytes to the platform either way.
Worth knowing

Object Lock, retention and legal hold are not available

You’ll see
GetObjectRetention and GetObjectLegalHold are refused; PutObjectLockConfiguration returns 405 MethodNotAllowed.
Why
Object Lock has to be enabled on a bucket at creation time upstream, and the platform does not offer it yet.
Instead
For backup tools that offer an immutability or object-lock mode, leave it off. Use a delete-restricted credential (permissions `read` and `write`, without `delete`) to get most of the protection: the backup agent can write new objects and read old ones, but cannot remove them.
Worth knowing

Only the STANDARD storage class exists

You’ll see
501 NotImplemented when a request sets x-amz-storage-class to GLACIER, STANDARD_IA, INTELLIGENT_TIERING or similar.
Why
Storage economics are expressed as a bucket's tier (hot or cool), chosen when the bucket is created, rather than per object.
Instead
Leave the storage class unset. If your tool sets one by default, point it at STANDARD. To store colder data, create a cool-tier bucket and write there. RestoreObject does not exist either, because nothing is ever archived.

Applies to AWS CLI, rclone.

Fixed

Kept for anyone who read this page before the fix landed.

fixedPath-style addressing is optional
fixedS3 API hostnames bypass browser-only bot screening