Supabase "__cf_bm" Cookie Rejected for Invalid Domain: Fix
Supabase

Supabase "__cf_bm" Cookie Rejected for Invalid Domain: Fix

The warning fires on every image, upload and WebSocket that touches <ref>.supabase.co in Firefox. It is Cloudflare's bot-management cookie being scoped to a Public Suffix List domain, so every browser drops it. Here is the verified cause, the cases where the noise hides a real 403 or auth bug, and how to tell them apart in two minutes.

8 min read
Supabase "__cf_bm" Cookie Rejected for Invalid Domain: Fix

Photo by Sasun Bughdaryan on Unsplash

Open any file served from a Supabase Storage bucket in Firefox and the console prints one line per asset:

text
Cookie "__cf_bm" has been rejected for invalid domain.

That is the warning in the GitHub issue supabase/supabase#37312, reported for a public image loaded through the Storage image-transformation endpoint (/storage/v1/render/image/public/...) on a <ref>.supabase.co project. The thread since collected the same line on plain object URLs, bucket uploads, and the Realtime WebSocket at /realtime/v1/websocket. The reporter called it "inconsequential, but it happens for each image loaded on the page", and that is the right summary: the warning is harmless, and it is also the first thing you see when something else has gone wrong, which is why it keeps getting blamed.

This article explains, with the actual response headers and the actual Public Suffix List entry, why the cookie is rejected by every browser, then walks through the four situations from the thread where the warning sat on top of a real failure.

Three verifiable facts combine here.

1. __cf_bm is Cloudflare's cookie, not Supabase's. Cloudflare's cookie reference states that it "places the __cf_bm cookie on end-user devices that access customer sites protected by Bot Management or Bot Fight Mode", that it "expires after 30 minutes of continuous inactivity", and that "a separate __cf_bm cookie is generated for each site that an end user visits". Supabase's API domain sits behind Cloudflare, so every response from Storage, Auth, PostgREST and Realtime can carry it.

2. The cookie is scoped to supabase.co. Requesting the image from the issue with curl -sI on 6 September 2026 returns HTTP/1.1 200 OK, Server: cloudflare, and this header (value shortened):

text
set-cookie: __cf_bm=zjsStpIsHSib...; HttpOnly; SameSite=None; Secure;
  Path=/; Domain=supabase.co; Expires=Sun, 06 Sep 2026 23:34:48 GMT

A commenter pasted the same shape a year earlier with domain=.supabase.co. Note the Domain attribute: the apex, not the project subdomain.

3. supabase.co is on the Public Suffix List. The PSL carries these lines in its private section:

text
// Supabase : https://supabase.io
// Submitted by Supabase Security <[email protected]>
supabase.co
realtime.supabase.co
storage.supabase.co
supabase.in
supabase.net

Supabase submitted its own domain so that each project ref is treated as a separate site, the same way vercel.app, pages.dev and netlify.app are listed. The point is tenant isolation: a cookie set by project-a.supabase.co must never be readable by project-b.supabase.co.

Put the three together. Cloudflare answers from <ref>.supabase.co with a cookie whose Domain is supabase.co. Browsers implement the cookie processing rules in RFC 6265 section 5.3: when the Domain attribute is a public suffix and does not exactly match the request host, the cookie is ignored. Firefox logs that decision as "rejected for invalid domain". Chromium makes the same decision silently, which is why a commenter saw only an orange warning icon on the Set-Cookie header in Chrome's Network panel and no console line.

So this is not a Firefox bug, not a Storage bug, and not something your @supabase/supabase-js configuration touches. It is Supabase's own PSL entry doing its job against a cookie it never asked for. The issue is labelled external-issue and internal-fix for that reason; the change has to happen in how Cloudflare is configured for the zone, and until it does, every supabase.co project shows the same line.

Why it is harmless for Storage, Auth and Realtime#

The rejection happens after the response has arrived. Nothing about the request, the status code, or the body changes.

  • Storage. Public object URLs and render/image URLs are unauthenticated GETs. The image in the issue returns 200 with Content-Type: image/jpeg alongside the rejected cookie. Signed URLs carry their token in the query string, not in a cookie.
  • Auth and PostgREST. supabase-js never relies on a cookie on the API domain. It sends your anon or publishable key in the apikey header and the user's JWT in Authorization: Bearer. The only auth cookies that matter, sb-<ref>-auth-token, are written by @supabase/ssr on your domain (www.example.eu, not supabase.co).
  • Realtime. The WebSocket upgrade response carries the same Set-Cookie. Dropping it has no effect on the upgraded connection, because the socket is already established and authenticates by sending the access token over the channel.

If everything on the page works and the only symptom is console volume, you are done: add -__cf_bm to the Firefox console filter box during development and move on.

When the warning is sitting on a real failure#

Every "it broke" report in the thread turned out to have a second, quieter error underneath. Check these before you spend another minute on the cookie.

403 "Feature not enabled in tenant" on image transforms#

One commenter's images stopped loading entirely and returned 403 Feature not enabled in tenant from /storage/v1/render/image/.... Removing the transform brought the assets back. Supabase's image transformation docs list the feature as Pro plan and above. A project downgraded to Free (or a new project created on Free after copying URLs from a paid one) keeps working for /object/public/ URLs and fails for /render/image/ ones.

Fix: upgrade the project, or drop the transform option so getPublicUrl returns the untransformed object URL:

ts
import { createClient } from '@supabase/supabase-js'
 
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
 
// Pro plan and above only:
const transformed = supabase.storage
  .from('artwork')
  .getPublicUrl('6f55179b.jpg', { transform: { width: 800, quality: 75 } })
 
// Works on every plan:
const plain = supabase.storage.from('artwork').getPublicUrl('6f55179b.jpg')

If you need resizing on the Free plan, resize on upload with sharp in a Route Handler and store the variants; the Storage upload guide covers that pipeline.

Uploads failing with restricted MIME types#

A June 2026 comment describes uploads of .kicad_pcb files failing with the cookie line in Firefox and failing silently in Chromium, until the bucket's "Restrict MIME types" toggle was disabled. The cookie was irrelevant; the upload was rejected by the bucket's allowed-MIME-type list because the file was detected as binary/octet-stream, which was not on the list. Read the JSON body of the failed POST /storage/v1/object/... response, then either add the detected type to the allow-list or pass an explicit contentType to upload(). A 403 with new row violates row-level security policy on the same request is a different problem, covered in the Storage RLS fix.

Realtime "prevents real-time functionality from working"#

A November 2025 comment blamed the cookie for a Realtime WebSocket that never delivered events. The Set-Cookie on the handshake cannot do that. Look at the WebSocket frames in the Network panel instead: a CHANNEL_ERROR or a close code points at Realtime authorisation (RLS on the table, private channels without a policy, or a stale token), and the Realtime not receiving events checklist walks through each one.

"Auth session missing" in a Next.js server component#

The one place where a cookie really is the problem is server-side auth, and it is your cookie, not Cloudflare's. If getClaims() or getUser() returns nothing on the server while the browser is logged in, check that sb-<ref>-auth-token exists on your own domain in DevTools, and that your server client is wired the way the current @supabase/ssr example does it:

ts
// utils/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
 
export async function createClient() {
  const cookieStore = await cookies()
 
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Called from a Server Component; the proxy refreshes sessions.
          }
        },
      },
    }
  )
}

Two cookie-domain mistakes produce a symptom that looks like this thread:

  • Passing cookieOptions: { domain: ... } to createServerClient with a value that is itself a public suffix. Preview deployments on vercel.app or pages.dev are the usual case: domain: 'vercel.app' is rejected for exactly the reason __cf_bm is, and the session never persists. Leave domain unset unless you are sharing a session across subdomains of a domain you own.
  • A proxy.ts (or middleware.ts on Next.js 15) that builds a fresh NextResponse and forgets to copy the refreshed cookies. The example's comment is blunt: return the supabaseResponse object as it is. The AuthSessionMissingError fix and the SSR session guide cover the matcher and cookie-forwarding details, and getClaims() vs getSession() explains which call to trust on the server.

A two-minute triage#

  1. Reproduce from the terminal so the browser is out of the picture:

    bash
    curl -sI "https://<ref>.supabase.co/storage/v1/object/public/<bucket>/<path>" \
      | grep -iE "^(HTTP|content-type|set-cookie)"

    200 plus the __cf_bm header means the asset is fine and the console line is cosmetic.

  2. Any 4xx: read the JSON body with curl -s (drop -I). 403 with "Feature not enabled in tenant" is the plan; 403 with an RLS message is a policy; 400 on upload is usually MIME or size.

  3. Realtime problems: inspect the WebSocket frames, not the handshake headers.

  4. SSR auth problems: look for sb-<ref>-auth-token on your domain in the Application panel. If it is missing, the bug is in setAll or the proxy, never in supabase.co.

Production notes#

  • Do not buy the custom-domain add-on to make this warning go away. It changes which hostname serves your API and Storage (api.example.eu), but whether Cloudflare still attaches a Domain attribute on that zone is not something this article can confirm, and the warning costs you nothing.
  • If your EU users load many images per page, the Firefox console noise is a developer-only cost. It does not affect Core Web Vitals, caching, or the CF-Cache-Status you see on the response.
  • Track the upstream issue for the internal-fix label to close; that is the only path to a clean console on supabase.co domains.

Frequently Asked Questions

|

Have more questions? Contact us

Written by

Mahdi Br
Mahdi Br

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.