Skip to content

POST /object/copy silently ignores metadata.cacheControl — S3 CopyObjectCommand missing MetadataDirective: 'REPLACE' #1109

@MathisL971

Description

@MathisL971

Summary

POST /storage/v1/object/copy accepts a metadata.cacheControl field, and the response shows the value applied. But the underlying S3 object's Cache-Control header is never updated, so subsequent fetches (including /render/image/public/...) keep serving the old value. Postgres storage.objects.metadata.cacheControl and the actual S3 metadata diverge silently.

The S3-protocol route PUT /storage/v1/s3/:Bucket/* with x-amz-copy-source is affected too — it routes through the same backend adapter.

Related: #270 is a 3-year-old feature request for an easier way to update cache-control. This issue is narrower: the existing copy endpoint already exposes metadata.cacheControl, but it doesn't function. Fixing this bug likely closes most of #270 without a new endpoint.

Repro

Tested against a current cloud project (single-tenant, AWS S3 backend), but the bug is in master adapter code so it reproduces against any deployment.

SUPABASE_URL="https://<your-ref>.supabase.co"
SERVICE_KEY="sb_secret_..."  # or service_role JWT
BUCKET="profile-pictures"     # any public bucket with an existing object
PATH_KEY="some/path/file.jpg" # an existing object currently at cacheControl=max-age=3600

# 1. Confirm starting state — both Postgres metadata AND served header are 3600
curl -s "$SUPABASE_URL/storage/v1/object/info/$BUCKET/$PATH_KEY?cb=$(date +%s)" \
  -H "Authorization: Bearer $SERVICE_KEY" | jq .cache_control
# → "max-age=3600"

curl -sI "$SUPABASE_URL/storage/v1/render/image/public/$BUCKET/$PATH_KEY?width=400" \
  | grep -i cache-control
# → cache-control: max-age=3600

# 2. Copy-in-place with metadata.cacheControl override
curl -s -X POST "$SUPABASE_URL/storage/v1/object/copy" \
  -H "Authorization: Bearer $SERVICE_KEY" \
  -H "Content-Type: application/json" \
  -H "x-upsert: true" \
  -d "{
    \"bucketId\":\"$BUCKET\",
    \"sourceKey\":\"$PATH_KEY\",
    \"destinationKey\":\"$PATH_KEY\",
    \"copyMetadata\":false,
    \"metadata\":{\"cacheControl\":\"max-age=604800\"}
  }" | jq '.metadata.cacheControl'
# → "max-age=604800"   ← response claims success

# 3. Verify — Postgres updated, S3 NOT updated
curl -s "$SUPABASE_URL/storage/v1/object/info/$BUCKET/$PATH_KEY?cb=$(date +%s)" \
  -H "Authorization: Bearer $SERVICE_KEY" | jq .cache_control
# → "max-age=604800"   ← Postgres reflects the new value

curl -sI "$SUPABASE_URL/storage/v1/render/image/public/$BUCKET/$PATH_KEY?width=401" \
  | grep -i cache-control
# → cache-control: max-age=3600   ← S3 still serves the old value (BUG)

Polling step 3's render request for several minutes never propagates — this isn't a CDN/Redis cache lag, it's a permanent divergence.

Expected

Cache-Control on the rendered/served response matches the value passed in metadata.cacheControl. Either:

  • The Postgres update is a side-effect of an authoritative S3 metadata update, or
  • The endpoint returns 4xx if it cannot rewrite S3 metadata, instead of silently succeeding.

Root cause

In src/storage/backend/s3/adapter.ts:357-388:

async copyObject(...) {
  const command = new CopyObjectCommand({
    Bucket: bucket,
    CopySource: encodeURIComponent(`${bucket}/${withOptionalVersion(source, version)}`),
    Key: withOptionalVersion(destination, destinationVersion),
    ...
    ContentType: metadata?.mimetype,
    CacheControl: metadata?.cacheControl,
    // ← MetadataDirective is never set
  })
  ...
}

Per AWS S3 docs, CopyObject defaults MetadataDirective to COPY. With COPY, any CacheControl / ContentType / ContentEncoding / Expires parameters supplied are silently discarded; the source object's metadata is preserved verbatim on the destination key. To honor the user-supplied values, the SDK call must include MetadataDirective: 'REPLACE'.

Note that even when Storage.copyObject is called with copyMetadata: false and a non-empty metadata arg (src/storage/object.ts:300-360), the adapter call still defaults to COPY because MetadataDirective is hardcoded-absent.

Suggested fix

Pass MetadataDirective based on caller intent. Minimal patch:

const command = new CopyObjectCommand({
  Bucket: bucket,
  CopySource: ...,
  Key: ...,
  ContentType: metadata?.mimetype,
  CacheControl: metadata?.cacheControl,
  MetadataDirective: metadata?.cacheControl || metadata?.mimetype ? 'REPLACE' : 'COPY',
})

Or expose MetadataDirective as an explicit param on the adapter signature and let the higher-level Storage.copyObject decide based on whether metadata was supplied. The S3-protocol handler already plumbs a MetadataDirective value through to Storage.copyObject (src/storage/protocols/s3/s3-handler.ts:1135-1157) — that intent should reach the adapter instead of being dropped.

Impact

  • Anyone trying to update an object's cacheControl (and presumably mimetype, contentEncoding) without re-uploading the file body silently gets no S3-level change.
  • The PostgREST-side cache_control reported by /object/info becomes a misleading source of truth — it shows the requested value, not the served value.
  • Workaround: download + re-upload via PUT /storage/v1/object/{bucket}/{path}, which goes through PutObject and correctly honors the Cache-Control HTTP header. Used this to backfill ~100 objects on our project.

Environment

  • Supabase cloud, AWS-backed S3 storage
  • Confirmed against master branch of supabase/storage as of 2026-05-17
  • Reproduces with both copyMetadata: true and copyMetadata: false
  • Reproduces against both the REST /object/copy route and the S3-protocol PUT ... x-amz-copy-source route (same adapter path)

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions