Skip to content

[Bug]: Image CDN returns 400 + empty body to any request with an Authorization header (breaks password-protected deploys) #3504

Description

@dahelg

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    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