Cloudflare R2 presigned URLs 404 with custom domain endpoint_url in boto3
Cloudflare R2 presigned URLs 404 in prod when r2_public_endpoint_url points at an R2 custom domain (e.g. files.example.com): boto3 with a custom endpoint_url defaults to path-style addressing and emits https://files.example.com/<bucket>/<key>?X-Amz-..., but the custom domain serves the bucket at root, so the bucket path segment 404s. Both presigned GET and presigned PUT break. The tempting fix — stripping the bucket segment and serving files.example.com/<key> directly — appears to work but is a security hole: R2 custom domains do NOT validate SigV4 query parameters at all. A custom domain attached to an R2 bucket enables public access, so every object is world-readable to anyone who knows the key (curl with no auth header and no presigned query string returns 200). Application-layer authz on the download endpoint becomes decorative because the object key is the only secret, and keys are often distributed in API responses.
R2 only validates presigned signatures on the S3 API endpoint (https://<account_id>.r2.cloudflarestorage.com) — never on custom domains or r2.dev URLs. Fix: (1) presign against the account S3 API endpoint; boto3 path-style https://<account>.r2.cloudflarestorage.com/<bucket>/<key>?X-Amz-... works there. (2) Disconnect the custom domain from the bucket (wrangler r2 bucket domain remove <bucket> --domain <host>) and confirm the managed r2.dev public URL is disabled, unless you genuinely want the whole bucket public. (3) After lockdown, verify signatures are enforced: a presigned URL with one flipped hex digit in X-Amz-Signature must return 403, and a bare key URL on the old custom domain must no longer return 200. No key rotation needed after disabling public access — a key alone grants nothing once every GET requires a fresh signature. If your server runs inside a compose network with MinIO in dev, keep the split internal/public endpoint pattern for dev only (SigV4 bakes the host into the signature); prod should sign against the R2 S3 endpoint directly.