Summary
On a Netlify deploy that has site-level password protection enabled (the platform-level basic-auth realm), every Sanity-CDN-sourced <Image> returns 400 Bad Request with an empty body. The cause is reproducible via curl: the Image-CDN endpoint refuses any request that carries an Authorization header — even one with fake credentials. Browsers automatically send the basic-auth header on every same-origin request after the user has entered the password, so once a deploy is protected, the entire optimized-image path collapses.
Environment
next 16.1.6
@netlify/plugin-nextjs 5.15.10 (also reproduces on 5.15.8)
- Netlify build-image:
noble-new-builds
- Site-level password protection: enabled
- Image source:
https://cdn.sanity.io/... (allow-listed via [images] remote_images in netlify.toml, but this is not the failing piece)
Reproduction
curl -I against the same _next/image?...&w=1920&q=75 URL on a deploy preview, varying only the request headers:
| Headers |
Response |
| (none — anonymous) |
200 OK, valid WebP body |
Authorization: Basic ZmFrZTpmYWtl (fake credentials) |
400 Bad Request, content-length: 0, cache-control: private |
Cookie: nf_jwt=fake |
200 OK, valid AVIF body |
Only the presence of the Authorization header (valid or invalid) flips the response. cache-control: private on the 400 suggests the CDN is deliberately treating Authorization-bearing requests as "uncacheable / not safe to optimize" rather than forwarding them.
The same flip is observable on /.netlify/images?url=… directly (without going through _next/image), which rules out the OpenNext URL forwarder as the culprit and points at the Image-CDN endpoint itself.
Why it matters
Site-level password protection is a common Netlify feature for staging / preview / customer-review environments. Combined with next/image, it produces a deploy where every image is broken even though:
- the source URL is allow-listed in
[images] remote_images
- the source CDN (Sanity in our case) returns 200 to identical params
- the same deploy serves images correctly to anonymous requests
The Authorization header is being honestly forwarded by the browser per the basic-auth spec, so this is not something user code can fix — proxy.ts middleware can already exclude _next/image from auth handling, but the browser still sends the header to the same origin. The application layer cannot strip request headers before they reach the Image-CDN edge.
Workaround
Switch to a custom Next.js Image loader (images.loader: 'custom' + loaderFile) that rewrites Sanity URLs with ?w=&q=&auto=format and lets the browser fetch directly from cdn.sanity.io. This bypasses the Netlify Image-CDN entirely, so the Authorization header never reaches it. Trade-off: lose _next/image optimization for non-Sanity sources (local /public/ assets, etc.) — but Sanity already provides equivalent transformation via its image API, so the workaround is roughly cost-neutral and saves Image-CDN credits.
Suggested fix
The Image-CDN edge handler should ignore the Authorization header on the incoming request — either strip it before processing, or treat its presence as orthogonal to the optimize/cache decision. Authorization is not security-relevant for cdn.sanity.io as a source (Sanity doesn't honor it for asset URLs), and the same reasoning applies to other public CDN sources.
If there's a documented reason for the current behavior (e.g. cache-key isolation under shared caching), it would be helpful to surface it: a non-empty error body explaining the rejection would have made this 1-hour debug instead of multi-hour.
Anonymized request/response
> GET /_next/image?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2F<projectId>%2F<dataset>%2F<asset>.jpg%3Fw%3D1400%26fit%3Dcrop%26auto%3Dformat&w=1920&q=75 HTTP/2
> Authorization: Basic ZmFrZTpmYWtl
< HTTP/2 400
< cache-control: private,max-age=0
< cache-status: "Netlify Edge"; fwd=miss
< content-type: text/plain; charset=utf-8
< content-length: 0
< server: Netlify
Same URL without the Authorization header → 200 + image bytes.
Happy to provide additional repro details (deploy ID, request IDs from x-nf-request-id) via support if useful, but did not include them here to keep this issue public-shareable.
Summary
On a Netlify deploy that has site-level password protection enabled (the platform-level basic-auth realm), every Sanity-CDN-sourced
<Image>returns 400 Bad Request with an empty body. The cause is reproducible viacurl: the Image-CDN endpoint refuses any request that carries anAuthorizationheader — even one with fake credentials. Browsers automatically send the basic-auth header on every same-origin request after the user has entered the password, so once a deploy is protected, the entire optimized-image path collapses.Environment
next16.1.6@netlify/plugin-nextjs5.15.10 (also reproduces on 5.15.8)noble-new-buildshttps://cdn.sanity.io/...(allow-listed via[images] remote_imagesinnetlify.toml, but this is not the failing piece)Reproduction
curl -Iagainst the same_next/image?...&w=1920&q=75URL on a deploy preview, varying only the request headers:200 OK, valid WebP bodyAuthorization: Basic ZmFrZTpmYWtl(fake credentials)400 Bad Request,content-length: 0,cache-control: privateCookie: nf_jwt=fake200 OK, valid AVIF bodyOnly the presence of the
Authorizationheader (valid or invalid) flips the response.cache-control: privateon the 400 suggests the CDN is deliberately treating Authorization-bearing requests as "uncacheable / not safe to optimize" rather than forwarding them.The same flip is observable on
/.netlify/images?url=…directly (without going through_next/image), which rules out the OpenNext URL forwarder as the culprit and points at the Image-CDN endpoint itself.Why it matters
Site-level password protection is a common Netlify feature for staging / preview / customer-review environments. Combined with
next/image, it produces a deploy where every image is broken even though:[images] remote_imagesThe Authorization header is being honestly forwarded by the browser per the basic-auth spec, so this is not something user code can fix —
proxy.tsmiddleware can already exclude_next/imagefrom auth handling, but the browser still sends the header to the same origin. The application layer cannot strip request headers before they reach the Image-CDN edge.Workaround
Switch to a custom Next.js Image loader (
images.loader: 'custom'+loaderFile) that rewrites Sanity URLs with?w=&q=&auto=formatand lets the browser fetch directly fromcdn.sanity.io. This bypasses the Netlify Image-CDN entirely, so the Authorization header never reaches it. Trade-off: lose_next/imageoptimization for non-Sanity sources (local/public/assets, etc.) — but Sanity already provides equivalent transformation via its image API, so the workaround is roughly cost-neutral and saves Image-CDN credits.Suggested fix
The Image-CDN edge handler should ignore the
Authorizationheader on the incoming request — either strip it before processing, or treat its presence as orthogonal to the optimize/cache decision. Authorization is not security-relevant forcdn.sanity.ioas a source (Sanity doesn't honor it for asset URLs), and the same reasoning applies to other public CDN sources.If there's a documented reason for the current behavior (e.g. cache-key isolation under shared caching), it would be helpful to surface it: a non-empty error body explaining the rejection would have made this 1-hour debug instead of multi-hour.
Anonymized request/response
Same URL without the Authorization header → 200 + image bytes.
Happy to provide additional repro details (deploy ID, request IDs from
x-nf-request-id) via support if useful, but did not include them here to keep this issue public-shareable.