Skip to content

Supabase Storage image transformations: billing, runtime behaviour, and the abuse surface

Supabase bills Storage image transformations per distinct origin image per billing cycle, rather than per transformation or per request.1 Re-rendering is therefore free, the 101st image in a cycle costs $5, and on a public bucket the count is influenceable by anyone who can read your HTML. The measured runtime behaviour disagrees with the docs in three places.

Runtime claims below were measured 2026-08-18 in ap-southeast-1, in two passes: an ad-hoc ~90-request matrix against two hand-created scratch projects (one Pro-plan org, one Free-plan org; fixtures 1200x800 JPEG, 4000x3000 JPEG, 56MP JPEG, 28.8MB PNG), then a scripted battery against a third project provisioned with OpenTofu (generated PNG/GIF/BMP/SVG fixtures, including a >25MB noise PNG and a >50MP flat PNG, so the two limits are tested independently). All three projects have been deleted. Billing-mechanics claims are from the Supabase docs and cited; where the docs and the runtime disagree, both are stated and the disagreement is marked. The one thing not observable from outside is the billing counter itself - see What was not provable.

TL;DR:

  • The billable unit is the distinct origin image transformed in a cycle: cost = $5 x ceil(max(0, N - 100) / 1000). The 101st image costs $5; images 102-1,100 are then free.
  • Only the four /render/image/* surfaces transform. Params appended to /object/public or /object/sign URLs are silently ignored - you get the full original and no error.
  • The docs’ dimension limit (1-2500px) is wrong at runtime: 2501 is accepted, and out-of-range requests silently clamp instead of erroring. The 25MB and 50MP source limits are enforced as documented.
  • The docs say image resizing is Pro-plan-only; a Free-plan org renders fine at runtime.
  • On a public bucket the render endpoint needs no auth. Rate limiting exists but is high: ~2% 429s at 500 parallel fresh renders, ~9% at 1,000. Every image name in your site’s markup is a billable unit an attacker can activate - whether or not you use transformations yourself.
  • Private buckets with signed render URLs are the only fix that closes the vector. Everything else is a posture decision between bill shock (spend cap off) and transform availability (spend cap on).

Client(any HTTP)/render/image/*(render endpoint)GET + paramsSmart CDN(Cloudflare edge)cache key:origin + variantImage renderer(imgproxy)Origin-image counter(org-level, per cycle)first transform oforigin this cyclemiss onlyPackagesceil(overage / 1000)past 100 quotaInvoice$5 per package

The billing docs define the model in three rules.1

  1. The unit is the distinct origin image. One image resized fifty ways and requested a million times in a cycle is one unit; a second image transformed once is a second unit.
  2. The count resets every billing cycle. The same image transformed again next month counts again.
  3. Overage is sold in packages of 1,000 at $5, ceiling-rounded, past a 100-image quota on Pro and Team.1
Origin images in cycleOverage past 100PackagesCharge
10000$0
10111$5
1,1001,0001$5
1,1011,0012$10
5,0004,9005$25

The quota (100) and the package size (1,000) do not align, so the marginal price of the 101st image is $5 and the marginal price of every image after that, up to 1,100, is zero. Effective per-image cost across a full package is half a cent, and exceeding the quota by one image costs the same as exceeding it by 1,000.

Number of variants, number of requests, cache hits or misses, egress: none of them move this metric. The only lever on the origin-image count is how many distinct files get rendered in the cycle. The docs’ own “Optimize usage” section (pre-generate variants, CDN caching, Cache-Control) reduces compute and egress but does nothing for this line item.1

Measured against the scratch Pro project, same source image, width=200&height=200:

SurfaceAuth requiredTransform applied?
/render/image/public/<bucket>/<file>none, on a public bucketyes
/render/image/authenticated/<bucket>/<file>service key or user JWTyes
/render/image/sign/<bucket>/<file>signed tokenyes, when the transform was embedded at sign time
/object/sign/<bucket>/<file> + appended paramssigned tokenno - full original served
/object/public/<bucket>/<file> + appended paramsnoneno - full original served
/render/image/public/<private-bucket>/<file>noneblocked, 400

The two silent-ignore rows are where integrations break. getPublicUrl() without the transform option, or hand-appending ?width=200 to an /object/public or /object/sign URL, returns the full original with a 200 and no error - you find out from your egress bill, not from logs. supabase-js does this correctly when you use the option: createSignedUrl(path, expiresIn, { transform }) POSTs the transform into the sign body and returns a /render/image/sign/... URL with the transform embedded in the token.2

The billable event can only be the GET on a render surface: creating a signed URL is a server-side signing POST that moves no image bytes, and nothing renders until the URL is fetched. Whether the platform’s counter increments at URL creation or at render is not observable from outside (see What was not provable); the architecture only permits the render.

Three measured disagreements, all reproducible with one curl each.

The docs say width and height must be integers between 1 and 2500.2 At runtime, width=2501 on a 4000x3000 source returns a 2501px render. The actual ceiling is 3000px: requests above that silently clamp to 3000, and requests above the source dimensions silently clamp to the source (no upscaling, no error). The documented bound is too low as a limit, and the runtime never errors on it.

The docs also say “Image Resizing is currently enabled for Pro Plan and above.”2 The scratch Free-plan org rendered a 200x200 transform with a 200 on the first request. Either the doc is stale or free-plan rendering is gated by Fair Use rather than a hard entitlement - but nothing at the render surface checks the plan. The billing page’s quota table also omits the Free plan entirely.1

For invalid parameters, width=abc returns a 400 (“must be integer”), but width=0 and a render with no params at all are accepted and return a processed near-identity of the source. Unknown junk parameters (&bust=xyz) are ignored entirely - which matters for caching, below.

Two limits are enforced exactly as documented.2 A source file over 25MB returns 400 “The source image file is too large to process”, and a source over 50 megapixels returns 400 “The source image resolution is too large to process”. Both are render-time checks - the same files upload to Storage without complaint. Check size and pixel count at upload time (a client check, or an upload-path function that rejects anything over 25MB or 50 megapixels), because Storage will not do it for you and the 400 only appears when the first render is requested.

Renders sit behind the Smart CDN on Cloudflare’s edge.3 Measured on the same variant requested repeatedly:

  • First render of a variant: ~0.25s. Repeat requests: ~0.05s, with cf-cache-status: HIT on the response.
  • The response carries cache-control: no-cache and gets cached at the edge anyway. The header is decorative for the CDN’s purposes; the cache key is the render URL.
  • Junk query parameters do not bust the cache: &bust=<random> returned the cached variant, same bytes, warm latency. Query-string cache-busting fails closed here: unknown parameters are normalised out of the cache key.
  • Each real variant (a new width/height/quality combination) is a separate cache entry and a separate cold render.
  • HEAD requests work and return the rendered variant’s headers.
  • Overwrite invalidation is unreliable. Across five trials of overwriting an object (confirmed x-upsert 200) and re-rendering the same variant, one invalidated within 60s and four served the stale variant past the poll window (three full 60-second polls, one 15-second poll). The Smart CDN docs describe purge-on-update; the runtime does not reliably match within a minute. If your flow overwrites images in place, version the object path (img-v2.jpg) instead of relying on invalidation.
  • Overwriting in place needs x-upsert: true (supabase-js upsert: true); a plain POST to an existing path returns 400. Check the overwrite’s 200 before reading any effect from it - a failed overwrite followed by a warm hit looks exactly like a stale cache.
  • Render responses carry no Vary: Accept despite honouring Accept negotiation (Accept: image/webp yields webp on the same URL that yields png without it). Whoever warms a URL first fixes its format at the edge until TTL - a webp-capable browser and a jpeg-only client can be served each other’s format. Do not let clients that negotiate different formats share one render URL: give each format its own variant URL and warm them separately. Whether the render endpoint’s format transform option also fixes the format the edge stores was not measured.

Per-origin counting is request-independent, so caching does not move the billing metric. It does cut egress and latency: a hot variant serves from the edge, and an attacker cannot force re-renders by mutating query strings.

Measured properties of the render endpoint on a public bucket:

  • No authentication: a plain curl with no Authorization header renders any object in a public bucket.
  • Rate limiting starts high. 200 parallel fresh renders all returned 200; at 500 parallel, 11 came back 429 (~2%); at 1,000 parallel, 88 (~9%). Refusals ramp rather than cut off, so a determined burst still lands most of its requests. It was measured on one project and one source, so whether the ceiling is per project, per IP or per org is open.
  • Enumeration requires a key. Listing a bucket without an Authorization header is rejected; listing with the anon key returns only what RLS allows (empty in the test). Names already in your markup need no enumeration.

Together these make the billing unit itself attacker-influenceable, a denial-of-wallet vector. Every object name visible in your site’s markup - every <img src=".../storage/v1/object/public/...">, every URL in a CSS or JS bundle - is an origin image an attacker can activate with one unauthenticated GET to the render endpoint, at 200 names in parallel.

The owner does not need to use transformations at all. If your site serves images straight from /object/public/ untransformed, an attacker hitting /render/image/public/ on those same names creates billable origin images you never asked for. A 5,000-image public bucket is 5,000 origin images - about $25 over quota - whether or not a single legitimate transform ever ran. And because the count resets each cycle, the attack is repeatable every month at zero marginal cost to the attacker.

The spend cap decides the outcome:4

  • Spend cap off (or Team plan and above): you pay the overage. The attack is a bill-inflation attack.
  • Spend cap on (the Pro default): you do not pay, but quota exhaustion pushes the org into the Fair Use grace period and eventual restriction.5 The attack becomes an availability attack on your legitimate transformations.

Variants of the same image are not a vector - extra sizes of an already-counted origin add no billable units, and cache-busting does not work (above). Egress amplification is also blunted by the edge cache.

Ranked by how completely they close the vector:

MitigationClosesResidual risk
Upload-time renditions, served directly (drop transform URLs)your own traffic’s count - nothing renders at read time, so nothing new countsthe render endpoint stays open on public buckets; third parties can still activate origins (below)
Private buckets + signed render URLs, short expirythe whole vector - the render surface requires a tokena leaked signed URL is replayable for its exact signed variant until expiry; keep expiries in minutes. (Measured: edited transform params on a signed URL are ignored - the token’s transform is what renders - and expiry is enforced. The authenticated render surface also enforces storage RLS.)
Your own edge in front (Cloudflare proxy/WAF/rate rules)rate and pattern control on the render pathorigin images rendered through it still count; it controls requests and leaves the count alone
Unguessable object names (UUIDs)farming of unlinked objectsany name already in your markup is still farmable
Spend cap onbill shockconverts the attack into quota exhaustion / transform unavailability
Monitoring the org usage page for origin-image spikesnothing - detection onlyyou find out after the units exist

For a bucket that must stay public, there is no configuration that prevents origin-image farming, because the render endpoint is unauthenticated by design and the billing unit counts exactly what the attacker can touch. The decision is which failure mode you prefer - pay the overage, or lose the feature under Fair Use - plus detection so the cycle repeats are visible. If the assets are not public content, moving them to a private bucket with signed render URLs is the only structural fix.

A migration off transforms tapers rather than stops when traffic comes from installed clients (mobile apps, third-party embeds): old versions keep calling transform URLs until they are upgraded, and every distinct image they touch still counts, so budget for the remainder of the cycle. The meter also stays open after your own usage ends. Moving to upload-time renditions zeroes your clients’ contribution, but on a public bucket anyone else can still hit the render endpoint and re-create billable origin images. Once your clients contribute nothing, any origin-image count above zero on the usage page is someone else.

Two workarounds fail despite appearing in the docs’ optimisation advice:1 pre-generating variants while still serving transform URLs (the origin still counts the first time anything renders it - the rendition pipeline only works because it drops the render endpoint from the read path entirely) and CDN caching (per-origin counting is request-independent).

PracticeEvidenceModule
Keep pre-warming and batch renders at or under 200 concurrent requests, and retry on 4290 of 200, 11 of 500 and 88 of 1,000 parallel fresh renders answered 429; I09 covered 500 and 1,000, the ad-hoc probe 200. Measured on one project and one source; whether the ceiling is per project, per IP or per org is openI09, ad-hoc probe
Reject sources over 25MB or 50 megapixels at upload time, in the client or an upload-path functionThe ad-hoc 28.8MB PNG and 56MP JPEG, and I04’s generated >25MB and >50MP PNGs, uploaded without complaint and returned 400 only at renderI04, ad-hoc probe
Add a test that fetches every transform URL your app builds and asserts the path contains /render/image/ and the body is smaller than the original/object/public and /object/sign ignore appended params: full original bytes with a 200 and no errorI02
Give each output format its own variant URLNo Vary: Accept is sent: webp and png served from one URL, vary header absent. Whether the format transform option pins the edge entry was not measuredI05
Enforce your own maximum variant size in the URL builderThe server clamps and never errors: widths 2500, 2501, 3000, 3001 and 5000 on a 4000x3000 source, none rejectedI03, ad-hoc probe
Version the object path (img-v2.jpg) when an image changes; do not rely on overwrite invalidation4 of 5 trials served the stale variant past the poll window (three 60s polls, one 15s poll)I06
Send x-upsert: true (supabase-js upsert: true) when overwriting in place, and check the 200 before reading any effectI06 harness note: a Storage POST to an existing path without it returns 400, and a failed overwrite followed by a warm hit reads as a stale cacheI06
Apply the private-bucket posture on Free-plan projects too200 with resized output on a Free-plan scratch project (not yet a scripted module), so plan gating does not protect the render surfacead-hoc probe
Gate /render/image/authenticated with a bucket-scoped storage.objects select policy, and test the denial as well as the allowA user JWT was denied without a select policy and rendered with oneI08

The origin-image counter is only visible on the dashboard usage page, which does not accept a Personal Access Token (the Management API exposes no usage endpoint for this metric, and the dashboard’s internal API answers a PAT with 401). So the correlation “a GET on a render surface increments the counter by one per distinct origin per cycle” is inferred from the billing docs1 plus the measured render behaviour, and was not observed. Cross-cycle reset and any dedup window are likewise unverified. Confirm on the usage page after a controlled burst before relying on the exact increment semantics for cost estimation.

Four sub-questions of the increment event are unobservable without counter access: do failed renders (the 400s for over-limit sources) count? Do HEAD requests count? Does a render of a missing object count? Does delete-then-reupload at the same path reset the origin? If your own distinct-image count comes in slightly below the billed figure, the increment event covers more than successful GETs of existing objects, and these four are the candidate causes.

Reporting latency is also unverified. The docs present the usage page as current-period, but the invoice line item is the only figure that is certain, and it lands at cycle close. During a fast ramp - and this metric can climb from inside the free quota through several $5 packages inside one cycle (5,000 distinct images is $25) - the usage page is the only early warning there is; treat any delay on it as part of the risk model, and check it on a schedule during launches rather than after the invoice.

ClaimHow it was checked
Billing unit, quota, package size, resetDocumented - Supabase billing page,1 not tested against the counter
/render/image/public transforms without authMeasured - 200, resized JPEG, no Authorization header
Signed render URL tampering fails closed; expiry enforcedMeasured - edited params ignored, token’s transform rendered; expired token rejected
Authenticated render enforces storage RLSMeasured - user JWT denied without select policy, allowed with one
Overwrite invalidation unreliableMeasured - 4 of 5 trials stale past the poll window (up to 60s) after confirmed overwrite
No Vary: Accept on render responsesMeasured - webp and png at one URL, vary header absent
Rate ceilingMeasured - 0/200 at 200 parallel, 11/500, 88/1000 429s
/object/public and /object/sign ignore appended paramsMeasured - full original bytes returned, 200
createSignedUrl with transform embeds it in a /render/image/sign URLMeasured - sign POST with transform body, fetched URL returns resized image
Dimension clamp at 3000 / source dims, no errorMeasured - width 2500/2501/3000/3001/5000 on a 4000x3000 source
25MB and 50MP source limits enforcedMeasured - 400s on a 28.8MB PNG and a 56MP JPEG
Docs’ 1-2500 bound wrongMeasured - width=2501 renders
Free-plan org rendersMeasured - 200 with resized output on a Free-plan scratch project
Edge cache HIT despite cache-control: no-cacheMeasured - cf-cache-status: HIT, ~0.05s warm vs ~0.25s cold
Junk params do not bust cacheMeasured - same bytes, warm latency, with random &bust=
No rate limiting below the ceilingMeasured - 50 sequential + 200 parallel fresh renders, all 200
Bucket listing needs auth; anon listing is RLS-filteredMeasured - rejected without header, [] with anon key
Counter increment eventNot observable - dashboard-only metric, PAT returns 401
  1. Supabase, “Manage Storage Image Transformations usage,” Supabase Docs. https://supabase.com/docs/guides/platform/manage-your-usage/storage-image-transformations ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8

  2. Supabase, “Image Transformations,” Supabase Docs. https://supabase.com/docs/guides/storage/serving/image-transformations ↩ ↩2 ↩3 ↩4

  3. Supabase, “Smart CDN,” Supabase Docs. https://supabase.com/docs/guides/storage/cdn/smart-cdn ↩

  4. Supabase, “Cost control,” Supabase Docs. https://supabase.com/docs/guides/platform/cost-control ↩

  5. Supabase, “Fair Use Policy,” Supabase Docs. https://supabase.com/docs/guides/platform/billing-faq#fair-use-policy ↩