supabase-js: v2.0.0 regression: Custom JWT (particularly in realtime Channels)

Bug report

Description

Using a custom JWT was a feature in v1 : https://supabase.com/docs/reference/javascript/auth-setauth

Per comment: https://github.com/supabase/gotrue-js/pull/340#issuecomment-1218065610 what we did was establish the custom header and it works for creating the client.

However, it’s not working with realtime channels. Upon inspection, the problem is that the internal initialization isn’t setting up RealtimeClient’s setAuth method: https://github.com/supabase/realtime#realtime-rls which in turn causes the web socket to not send the JWT on the messages and heartbeats…

The result is that channels fail the RLS.

A second problem is with setting up a new valid JWT when the previous one expired: there’s no way to do it. Not in the supabase client and much less in the realtime client.

To Reproduce

  1. Create a database with a restrictive policy based on a custom JWT
  2. Create a JWT with a 1 minute expiration time
  3. Create a client using the method explained in https://github.com/supabase/gotrue-js/pull/340#issuecomment-1218065610 4.a. Let 2 minutes go by and try to make any call to supabase using supabaseClient. Access is denied because the JWT has expired. Try to update the JWT -> there’s no way to do it. Would have to create a new client but that defeats the whole thing. 4.b. Create a channel subscription -> ‘fails’ by not providing access to rows to which the user has access acording to the policy)

Upon inspection of the websocket, as it was expected, the messages and heartbeats don’t include the custom JWT… Why whould they? We’ve only set the header /directly/ and that’s it…

Workaround

What we’ve done is changing from protected to public the supabase client’s class elements: “headers” in SupabaseClient “realtime” in GoTrueClient and we’re calling a) supabaseClient.realtime.setAuth(JWT) b) supabase.auth.headers.Authorization = Bearer ${JWT}; with the customToken…

That keeps everything working…

Tried forking and creating an updateJWT method, but realized we’re not very familiar with the modularization philosophy of the project and was most likely being both overkill about it. Also, were falling short because an alternate method is quite likely required for the initialization, since using the headers option in the createClient doesn’t impact the realtime client.

About this issue

  • Original URL
  • State: closed
  • Created 2 years ago
  • Reactions: 9
  • Comments: 52 (14 by maintainers)

Most upvoted comments

@kangmingtay Based on your comment, I made the realtime work with custom jwt. Here is how I handle it:

export const getSupabaseRealtime = (exchangeToken: string) => {
  const intialOptions = {
    realtime: {
      headers: {
        apikey: `Bearer ${exchangeToken}`,
      },
      params: {
        apikey: ConfigService.getPropertyAnonKey(),
      },
    },
  };
  const realtimeClient = createClient(
    ConfigService.getPropertySupabaseUrl(),
    ConfigService.getPropertyAnonKey(),
    intialOptions
  );
  realtimeClient.realtime.setAuth(exchangeToken);
  return realtimeClient;
};

@w3b6x9 I’m migrating to Supabase from Firebase. I’m keeping firebase auth for now since Supabase doesn’t have anonymous auth yet. To summarize and clarify the issues based on the above, here’s what I currently do:

  1. Sign in the user with firebase auth
  2. Use an edge fn to verify the user’s Firebase token then return a new token signed with the Supabase jwt key
  3. Create a client with the following config
const db = createClient<Database>(creds.url, creds.key, {
        auth: authOpts,
        global: {
            headers: {
                authorization: `Bearer ${key}`, // my custom token
            },
        },
        realtime: {
            headers: {
                apikey: key, // my custom token returned from my edge fn --docs have it backwards
            },
            params: {
                apikey: creds.key, // the supabase anon key
            },
        },
    })

This method works up to the point where I need to refresh the user’s token, which I want to do whenever their firebase token gets refreshed. I can call my edge fn to verify the new firebase token and generate a new supabase token, but then as @LuisAngelVzz pointed out there isn’t a great way to set this new token in the existing client. For now I will use a workaround like was posted above.

I think there are two things needed to solve these problems:

  1. Provide a method on supabase-js to explicitly handle setting a new custom auth token on an existing client
  2. And/or – provide a signUpWithCustomToken/signInWithCustomToken option as suggested by @magicseth to create a real gotrue session based on the custom token.

Are either of these things currently planned to be implemented? If so, when?

I hope this effectively summarizes the issue. IMO this is likely to be a problem faced by almost everyone attempting to migrate from Firebse so I hope it can get bumped up to a higher priority by the Supabase team.

What I was hoping for was a SignInWithToken(…) api that would take the JWT, create a session, and call the authstatechanged callbacks.

As is it feels daunting reaching in to protected fields in multiple files to achieve this.

@w3b6x9 We’ve just been testing this and want to highlight that the docs are slightly off. They say to pass the Supabase key into headers and the custom JWT into params, whereas it’s actually the other way around.

What worked for us is:

const { createClient } = require('@supabase/supabase-js')

const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY, {
  realtime: {
    headers: {
      apikey: 'custom-supabase-signed-jwt-token',
    },
    params: {
      apikey: 'supabase-anon-key',
    },
  },
})

Whereas the docs say:

const { createClient } = require('@supabase/supabase-js')

const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY, {
  realtime: {
    headers: {
      apikey: 'supabase-signed-token',
    },
    params: {
      apikey: 'your-custom-token',
    },
  },
})

Note that the description of the Supabase anon key in the docs is also confusing:

“The apikey in headers must be either the anon or service_role token that Supabase signed”

Other than this needing to be the apikey in params not headers, it should probably also say “that Supabase provides” rather than “that Supabase signed”. This caused a mini goose chase when debugging.

For other people (especially Clerk auth users) using a custom JWT, here are two patterns I’m using. Feel free to give feedback if you have a better way:

  1. Create new clients frequently but only when there is a new access token aka the JWT expired). I use this for real time and browser clients for all non-real-time requests (see real-time below)
import { SupabaseClient, createClient } from '@supabase/supabase-js'

let supabaseClientPool: Record<string, SupabaseClient> = {}

export const supabaseJwtClient = (accessToken: string) => {
  if (supabaseClientPool[accessToken]) {
    return supabaseClientPool[accessToken]
  }
  purgePoolOnBrowser()
  const client = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      global: { headers: { Authorization: `Bearer ${accessToken}` } },
    },
  )
  client.realtime.setAuth(accessToken)
  supabaseClientPool[accessToken] = client
  return client
}

const purgePoolOnBrowser = () => {
  if (typeof window !== 'undefined') {
    supabaseClientPool = {}
  }
}
  1. For real-time, I’m basically checking the JWT and refreshing it 5 seconds before it’s set to expire (otherwise the realtime channel will get closed if the JWT expires):
import { useCallback, useEffect, useState } from 'react'
import { supabaseJwtClient } from '@/lib/supabase/supabaseJwtClient'
import { useJwt } from '@/lib/auth/useJwt'
import { devLog } from '../logging/devLog'
import {
  REALTIME_POSTGRES_CHANGES_LISTEN_EVENT,
  REALTIME_SUBSCRIBE_STATES,
  RealtimeChannel,
} from '@supabase/supabase-js'

export const useRealtimeTable = <T extends { id: string }>(
  tableName: string,
) => {
  const { getJwt, getJwtExpiration } = useJwt()
  const [records, setRecords] = useState<T[]>([])

  const fetchRecords = useCallback(async () => {
    const supabase = supabaseJwtClient(await getJwt())
    const { data, error } = await supabase.from(tableName).select('*')

    if (error) {
      devLog(`Error fetching ${tableName}:`, error)
    } else {
      setRecords(data || [])
    }
  }, [tableName, getJwt])

  const subscribeToTable = useCallback(async () => {
    let supabase = supabaseJwtClient(await getJwt())
    let lastChannelState: REALTIME_SUBSCRIBE_STATES | null = null
    let channel: RealtimeChannel | null = null

    const subscribe = () => {
      channel = supabase
        .channel(`${tableName}-changes`)
        .on(
          'postgres_changes',
          {
            event: REALTIME_POSTGRES_CHANGES_LISTEN_EVENT.ALL,
            schema: 'public',
            table: tableName,
          },
          (payload: any) => {
            if (
              payload.eventType ===
              REALTIME_POSTGRES_CHANGES_LISTEN_EVENT.UPDATE
            ) {
              setRecords((prevRecords) =>
                prevRecords.map((record) =>
                  record.id === payload.new.id ? payload.new : record,
                ),
              )
            }
            if (
              payload.eventType ===
              REALTIME_POSTGRES_CHANGES_LISTEN_EVENT.INSERT
            ) {
              setRecords((prevRecords) => [...prevRecords, payload.new])
            }
            if (
              payload.eventType ===
              REALTIME_POSTGRES_CHANGES_LISTEN_EVENT.DELETE
            ) {
              setRecords((prevRecords) =>
                prevRecords.filter((record) => record.id !== payload.old.id),
              )
            }
          },
        )
        .subscribe(async (status, error) => {
          devLog(`[${tableName}]: `, status)
          if (
            status === REALTIME_SUBSCRIBE_STATES.CLOSED &&
            lastChannelState === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED
          ) {
            devLog('Channel closed (e.g. JWT expiration), resubscribing...')
            await refreshToken()
            cleanup()
            subscribe()
          }
          if (error) {
            console.error(`[${tableName}] Real-time channel error:`, error)
          }
          lastChannelState = status as REALTIME_SUBSCRIBE_STATES
        })
    }

    const cleanup = () => {
      if (channel) supabase.removeChannel(channel)
    }

    const refreshToken = async () => {
      const token = await getJwt()
      supabase.realtime.setAuth(token)
    }

    const automaticallyRefreshJwt = async () => {
      const expiration = await getJwtExpiration()
      if (expiration) {
        const currentTime = Math.floor(Date.now() / 1000)
        // Refresh 5 seconds before expiration
        const timeUntilExpiration = expiration - currentTime - 5
        if (timeUntilExpiration > 0) {
          setTimeout(async () => {
            await refreshToken()
            automaticallyRefreshJwt() // Set up the next refresh
          }, timeUntilExpiration * 1000)
        }
      }
    }

    window.addEventListener('beforeunload', cleanup)
    subscribe()
    await automaticallyRefreshJwt()

    // Clean up on unmount
    return () => {
      window.removeEventListener('beforeunload', cleanup)
      cleanup()
    }
  }, [tableName, getJwt, getJwtExpiration])

  useEffect(() => {
    fetchRecords()
    subscribeToTable()
  }, [tableName, fetchRecords, subscribeToTable])

  return { records }
}

And just for completeness, my JWT code (for Clerk auth):

import { useCallback } from 'react'
import { useAuth } from '@clerk/nextjs'
import jwtDecode from 'jsonwebtoken'

export const useJwt = () => {
  const { getToken } = useAuth()

  const getJwt = useCallback(async (): Promise<string> => {
    const jwt = await getToken({ template: 'supabase' })
    if (!jwt) throw new Error('No JWT found')
    return jwt
  }, [getToken])

  const getJwtExpiration = useCallback(async (): Promise<number | null> => {
    const jwt = await getJwt()
    try {
      const decoded = jwtDecode.decode(jwt) as { exp: number } | null
      return decoded?.exp || null
    } catch (error) {
      console.error('Failed to decode JWT', error)
      return null
    }
  }, [getJwt])

  return { getJwt, getJwtExpiration }
}

I did! See: https://supabase.com/docs/guides/realtime/postgres-changes#custom-tokens

And you can use the Inspector with a custom JWT here: https://realtime.supabase.com/inspector/new

Gonna close this one now!

hey @evelant, apologies for the late reply as the team was quite busy with launch week! just to clarify, i’m assuming that you’d want updateToken(newToken: string) to take care of setting the new custom token properly across the Supabase services like such:

db.headers.Authorization = `Bearer ${key}`
db.auth.headers.Authorization = `Bearer ${key}`
db.rest.headers.Authorization = `Bearer ${key}`
db.realtime.setAuth(key)
db.functions.setAuth(key)

I do want to mention that if your custom token is short-lived and you’re refreshing it yourself you’ll still have to call Realtime’s setAuth in order to send the refreshed token to Realtime’s servers.

Ha…! Thanks…

I wonder if the issue of a customJWT refresh method is on the todo’s ? Not urgent… My workaround works without issues… (There may be something I may be missing, though…)

It’s all documented on https://github.com/supabase/gotrue-js/issues/701 along with patches to unprotect the applicable class properties:

this.supabase.headers.Authorization = Bearer ${supabaseToken};
this.supabase.auth.headers.Authorization = Bearer ${supabaseToken};
this.supabase.rest.headers.Authorization = Bearer ${supabaseToken};

which have to be updated in addition to calling setAuth again when the JWT expires…

Annoyingly I’m still having problems with updating the client’s headers when I refresh my custom token. My code posted above worked fine against the local supabase instance in docker but is failing against my live staging instance. I verified that I am getting a correctly signed updated token but unfortunately postgrest and edge function requests are failing after I attempt to update the client.

@w3b6x9 @hf @kangmingtay Could you please provide an example of how you would update a custom token on an existing client instance?

I’m really struggling with this and it’s the last major piece of the puzzle necessary to transition my app to Supabase. We can’t go live with the switch to Supabase until token refresh is working reliably.

Hey folks, I wanted to address the current custom token situation with your hosted Supabase project’s Realtime.

The Realtime docs regarding custom tokens here are correct: https://supabase.com/docs/guides/realtime/extensions/postgres-changes#custom-tokens.

I’ve also created this Replit as an example of it working: https://replit.com/@w3b6x9/RealtimeCustomToken. You can scroll to the bottom for database setup if you would like to reproduce it.

Here’s how the Supabase provided anon token and your custom token are being used once passed in to supabase-js client.

Providing this here for convenient reference:

const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY, {
  realtime: {
    headers: {
      apikey: 'supabase-provided-anon-token',
    },
    params: {
      apikey: 'your-custom-token',
    },
  },
})

The Supabase anon token that is provided to your Supabase project is checked in our Cloudflare API gateway to make sure that the project is valid and active. Then the gateway forwards the request, along with your custom token found under realtime.params.apikey in the options object, to our Realtime servers.

Realtime servers will check your custom token and make sure it’s signed by your project’s JWT secret. If you’re listening to Postgres changes it will then insert this custom token’s claims into your project database’s realtime.subscription table.

If you decide to reproduce my Replit example, you’ll see in the realtime.subscription table that the claims is that of the custom signed token.

Like @evelant mentioned earlier (https://github.com/supabase/supabase-js/issues/553#issuecomment-1492242754) I have removed the protected flag so you can call supabaseClient.realtime.setAuth(token) when you want to pass Realtime a refreshed token without ts complaining.

Note that the description of the Supabase anon key in the docs is also confusing:

“The apikey in headers must be either the anon or service_role token that Supabase signed”

Other than this needing to be the apikey in params not headers, it should probably also say “that Supabase provides” rather than “that Supabase signed”.

@yamen thanks, this is good feedback! will go and update the docs so it’s more obvious.

Ok, my bad, it was a silly mistake, essentially a typo. I had authorization where I should have had Authorization. The workaround of manually issuing a new token and then setting it on the headers of the various client libraries seems to be working just fine now.

@rdylina Frustratingly this workaround doesn’t seem to actually work. Once I’ve refreshed my token I still get invalid token failures. JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 5). As far as I can tell from reading all the code in the libraries this workaround should work so I’m definitely missing something.

@laktek @hf @soedirgo @J0 Could you provide some input on this issue please? Custom tokens + refresh is the final blocker for my team to finish migrating from Firebase to Supabase.

OK I seem to have gotten it working. Not sure why this is working, it’s not even close to what the docs suggest.

If I create a client like this – note I commented out any realtime config

    const db = createClient<Database>(creds.url, creds.key, {
        auth: authOpts,
        global: {
            headers: {
                authorization: `Bearer ${key}`,
            },
        },
        // realtime: {
        //     // headers: {
        //     // apikey: `Bearer ${key}`,
        //     // },
        //     params: {
        //         // apikey: creds.key,
        //         accessToken: `Bearer ${key}`,
        //     },
        // },
    })

    //@ts-ignore - this is a private method but we must call it to make auth work on realtime channels with our custom token
    db.realtime.setAuth(key)

then call realtime.setAuth(key) and now realtime gets the correct info from my custom token. RLS works and realtime.subscription shows the correct claims {"aud": "authenticated", "exp": 1680230955, "sub": "zsGXfLygdCt2OF4Vt0hGrodAuK7a", "role": "authenticated", "app_metadata": null, "user_metadata": null}

I’ll need to test this some more (specifically token refresh) but this may be a good enough workaround until the problems with custom tokens get fixed.

@rdylina After digging into it a little bit it seems the issue, at least for me, isn’t RLS specifically. Instead the problem is that realtime postgres change listeners are authenticated as anon, despite me setting the headers as suggested.

Requests from postgres-js work fine with the token, they get the proper authenticated role and sub. Realtime however does not appear to use the custom token and treats the connection as anon so it probably fails most RLS rules preventing anonymous access.

@w3b6x9 having realtime work with a custom token is a requirement for us to switch from firebase to supabase. We can’t migrate our auth yet so we have to continue using firebase auth which means we need to have custom tokens working or we can’t migrate to Supabase. Any further pointers or input on this issue would be greatly appreciated!

edit: looking at the realtime.subscription table I can see that the claims column is bogus for all my realtime listeners. It’s {"exp": 1983812996, "iss": "supabase-demo", "role": "anon"} instead of the actual custom token I’m sending from the client.

I also see this in the docker logs for the local realtime container when I try to start a subscription without giving anon privilege

Subscribing to PostgreSQL failed: {:error, "Subscription insert failed with error: ERROR P0001 (raise_exception) invalid column for filter docId. Check that tables are part of publication supabase_realtime and subscription params are correct: %{\"event\" => \"*\", \"filter\" => \"docId=eq.212b057f-7e32-4c40-aa90-8397df960194\", \"schema\" => \"public\", \"table\" => \"logChunks\"}"}

Any news on this? It’s a regression… Any way to get the devs attention?