Skip to content

Realtime REST fallback in supabase-js sends empty Authorization header when no session (500 error) #1590

@Schlumen

Description

@Schlumen

Bug report

Describe the bug

When using @supabase/supabase-js to broadcast to Realtime without subscribing to the channel first, the client falls back to the REST endpoint /realtime/v1/api/broadcast. In this REST fallback, if there is no user session/access token, the library sends an empty Authorization header (exactly Authorization: \r\n, with no value at all). This results in a 500 Internal Server Error from the Realtime endpoint.

Notes:

  • The empty Authorization header occurs even if global.headers.Authorization is configured on the client.
  • Manually calling the REST endpoint with only the apikey header (and no Authorization header) works and returns 202 Accepted.
  • Calling client.realtime.setAuth(<token>) (server-side) or using WebSocket send after subscribe() also avoids the issue.

It looks like the REST fallback builds/forces an Authorization header from the current auth session, and if none exists, it still emits an empty header instead of omitting it (or honoring global.headers).

To Reproduce

Minimal server-side repro (no user session, Service Role client):

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!, // server-side only
  {
    auth: { autoRefreshToken: false, persistSession: false },
    // Setting global headers does not help for the fallback:
    global: {
      headers: {
        apikey: process.env.SUPABASE_SERVICE_ROLE_KEY!,
        Authorization: `Bearer ${process.env.SUPABASE_SERVICE_ROLE_KEY!}`,
      },
    },
  }
)

// No ch.subscribe() here → triggers REST fallback:
const ch = supabase.channel(`channel:${'some-channel-id'}`, {
  config: { private: true },
})

const ok = await ch.send({
  type: 'broadcast',
  event: 'new-message',
  payload: { hello: 'world' },
})

// Result: HTTP 500 from /realtime/v1/api/broadcast.
// Request contains an EMPTY Authorization header.
console.log('send ok?', ok)

Observed request headers (captured with Wireshark; redacted):

POST /realtime/v1/api/broadcast
apikey: <service_role_jwt>
Authorization: 
Content-Type: application/json

If I instead call the REST endpoint manually with no Authorization header:

await fetch(`${process.env.NEXT_PUBLIC_SUPABASE_URL}/realtime/v1/api/broadcast`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    apikey: process.env.SUPABASE_SERVICE_ROLE_KEY!,
  },
  body: JSON.stringify({
    messages: [{
      topic: `channel:${'some-channel-id'}`,
      event: 'new-message',
      payload: { hello: 'world' },
      private: true,
    }],
  }),
})

→ Returns 202 Accepted and clients receive the broadcast as expected.

Workarounds that also work:

  • Use WebSocket send by calling ch.subscribe() and waiting for SUBSCRIBED before ch.send(...).
  • Call supabase.realtime.setAuth(<service_role_jwt>) server-side so the fallback’s Authorization is not empty.

Expected behavior

  • If no session/access token is present, the REST fallback should omit the Authorization header (or allow overriding it), rather than sending an empty Authorization: line.
  • Alternatively, the fallback could honor global.headers.Authorization when no session exists.
  • Additionally, the Realtime REST endpoint should not return 500 for an empty/invalid Authorization header; a 401/400 would be more appropriate.

Screenshots

N/A — network capture shows the header exactly as Authorization: \r\n (no value) when using channel.send() without subscribe().

System information

  • OS: Windows 11 Home
  • Browser (if applies): N/A (server-side usage)
  • Version of supabase-js: ^2.57.4
  • Version of Node.js: 22.x

Additional context

  • Client is created with Service Role key on the server only (never in the browser).
  • auth.persistSession is false, so there is intentionally no user session.
  • Setting global.headers.Authorization does not affect the REST fallback.
  • This is easy to hit in practice: calling channel.send() before subscribe() is a common mistake/edge case, and receiving a 500 due to an empty header is confusing.
  • Note: The HTTP 500 status was only visible via a network capture (Wireshark); channel.send(...) merely returned "error" without status code or body, which significantly complicated debugging.

Suggested fix ideas:

  • In @supabase/supabase-js, if access_token is falsy during REST fallback, do not set Authorization at all (or use realtime.getAuth() / global.headers as a fallback).
  • In Realtime, return 401/400 instead of 500 for empty/invalid Authorization headers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions