Justin
Justin6d ago

Supabase Auth and Convex

Hey everyone! I've been trying to setup Supabase auth to be used with Convex (their free tier is more appealing than Clerk) but I've not been about to get it working, It errors out Uncaught Error: Not authenticated This is for a React Native mobile application. I changed the auth.config.ts to
export default {
providers: [
{
type: 'jwt',
domain: process.env.CONVEX_OIDC_ISSUER,
key: process.env.CONVEX_JWT_KEY!,
applicationID: 'supabase-client',
},
],
};
export default {
providers: [
{
type: 'jwt',
domain: process.env.CONVEX_OIDC_ISSUER,
key: process.env.CONVEX_JWT_KEY!,
applicationID: 'supabase-client',
},
],
};
From there I have a hook useSupabaseConvexAuth
import { supabase } from '@/lib/supabase';
import { useEffect, useState } from 'react';

export function useSupabaseConvexAuth() {
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
const fetchSession = async () => {
const { data } = await supabase.auth.getSession();
setAccessToken(data?.session?.access_token ?? null);
setIsLoading(false);
};

fetchSession();

const { data: listener } = supabase.auth.onAuthStateChange(
(_event, session) => {
console.log(session?.access_token);
setAccessToken(session?.access_token ?? null);
}
);

return () => {
listener.subscription.unsubscribe();
};
}, []);

return {
isLoading,
isAuthenticated: !!accessToken,
fetchAccessToken: async () => accessToken,
};
}
import { supabase } from '@/lib/supabase';
import { useEffect, useState } from 'react';

export function useSupabaseConvexAuth() {
const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
const fetchSession = async () => {
const { data } = await supabase.auth.getSession();
setAccessToken(data?.session?.access_token ?? null);
setIsLoading(false);
};

fetchSession();

const { data: listener } = supabase.auth.onAuthStateChange(
(_event, session) => {
console.log(session?.access_token);
setAccessToken(session?.access_token ?? null);
}
);

return () => {
listener.subscription.unsubscribe();
};
}, []);

return {
isLoading,
isAuthenticated: !!accessToken,
fetchAccessToken: async () => accessToken,
};
}
which is then used in my _layout.tsx
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { SplashScreen, Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';

import { useColorScheme } from '@/hooks/useColorScheme';
import { useSupabaseConvexAuth } from '@/hooks/useSupabaseConvexAuth';
import { Providers } from '@/providers';
import { ConvexProviderWithAuth, ConvexReactClient } from 'convex/react';
import { useEffect } from 'react';

SplashScreen.preventAutoHideAsync();

const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);

export default function RootLayout() {
const auth = useSupabaseConvexAuth();
const colorScheme = useColorScheme();
const [loaded] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});

useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);

if (!loaded) {
return null;
}

return (
<Providers>
<ConvexProviderWithAuth client={convex} useAuth={() => auth}>
<ThemeProvider
value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}
>
<Stack>
<Stack.Screen name='(tabs)' options={{ headerShown: false }} />
<Stack.Screen name='+not-found' />
</Stack>
<StatusBar style='auto' />
</ThemeProvider>
</ConvexProviderWithAuth>
</Providers>
);
}
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { SplashScreen, Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import 'react-native-reanimated';

import { useColorScheme } from '@/hooks/useColorScheme';
import { useSupabaseConvexAuth } from '@/hooks/useSupabaseConvexAuth';
import { Providers } from '@/providers';
import { ConvexProviderWithAuth, ConvexReactClient } from 'convex/react';
import { useEffect } from 'react';

SplashScreen.preventAutoHideAsync();

const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!);

export default function RootLayout() {
const auth = useSupabaseConvexAuth();
const colorScheme = useColorScheme();
const [loaded] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});

useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);

if (!loaded) {
return null;
}

return (
<Providers>
<ConvexProviderWithAuth client={convex} useAuth={() => auth}>
<ThemeProvider
value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}
>
<Stack>
<Stack.Screen name='(tabs)' options={{ headerShown: false }} />
<Stack.Screen name='+not-found' />
</Stack>
<StatusBar style='auto' />
</ThemeProvider>
</ConvexProviderWithAuth>
</Providers>
);
}
I've set the env variables on the Convex dashboard and have my Supabase public and private keys. Any suggestions or feedback on how I could get this setup? I can sign into the app and log the access_token, but it appears to not be making it to Convex.
13 Replies
erquhart
erquhart6d ago
type and key in your auth.config.ts aren't valid properties, are you seeing them documented somewhere?
Justin
JustinOP6d ago
For the auth.config.ts that was taken from chatGPT. I've seen them referenced here: https://labs.convex.dev/auth/setup/manual https://docs.convex.dev/auth/advanced/custom-jwt What should the keys be updated to?
erquhart
erquhart6d ago
There's no key, type can be set to customJwt to avoid setting up an oidc provider, as that last doc you linked shows If supabase auth does oidc, you only need applicationId and domain domain should match the iss claim, applicationId should match the aud claim Someone here is saying they don't actually implement full oidc, as they don't expose well-known endpoints: https://github.com/orgs/supabase/discussions/6547#discussioncomment-9049664 May or may not be accurate But if that's true, that customJwt doc you linked would be your path forward, as long as you can find the jwks endpoint that supabase auth uses All of that said, have you considered just using Convex Auth? I suspect there may be more hiccups on the path of using Supabase Auth, but if that's what you're set on I'll def help however I can.
Justin
JustinOP6d ago
I'll have to dig into the supabase stuff more. Their own documentation isn't even correct. It says to enable OIDC via a menu, that is no longer present I have considered using Convex Auth and went through this setup for React Native: https://labs.convex.dev/auth I hit a few hiccups after getting this setup implemented If you wouldn't mind me hitting you up with a few questions I'd be down for trying to smooth out the implementation on my end. I feel it would be easier than this
erquhart
erquhart6d ago
For sure - I've used Convex Auth with Expo in production, happy to help
Justin
JustinOP6d ago
I really appreciate that! Thank you 😃 would you like me to DM you or keep it all in this thread? After going through that setup guide, how did you handle checking if a user had signed in or not and redirecting to a /login page or showing content? For example on supabase I just made a hook
export function useSupabaseAuth() {
const { user, session, loading } = useAuthContext();
return {
isSignedIn: !!user,
session,
user,
loading,
};
}
export function useSupabaseAuth() {
const { user, session, loading } = useAuthContext();
return {
isSignedIn: !!user,
session,
user,
loading,
};
}
Then in my _layout.tsx inside (tabs)
if (!isSignedIn) {
return <Redirect href='/login' />;
}
if (!isSignedIn) {
return <Redirect href='/login' />;
}
Are you making hooks to interact with convex's auth or is it all functions that are added to the convex directory?
erquhart
erquhart6d ago
We can keep the discussion here, makes it discoverable for others in the future. All I use:
import { useConvexAuth} from 'convex/react'

const SomeComponent = () => {
const { isAuthenticated, isLoading } = useConvexAuth()
// ...
}
import { useConvexAuth} from 'convex/react'

const SomeComponent = () => {
const { isAuthenticated, isLoading } = useConvexAuth()
// ...
}
You can redirect based on those statuses If isLoading, the authenticated state might still change, so you want to wait until isLoading settles to false.
Justin
JustinOP5d ago
Went through the project setup again via the Convex Auth documentation. I am doing OTP (OAuth will be later) I followed this guide: https://labs.convex.dev/auth/config/otps It encounters an error validating the code
5/28/2025, 5:00:24 PM [CONVEX M(auth:store)] [INFO] '`auth:store` type: createVerificationCode'
5/28/2025, 5:00:48 PM [CONVEX M(auth:store)] [INFO] '`auth:store` type: verifyCodeAndSignIn'
5/28/2025, 5:01:21 PM [CONVEX M(auth:store)] [INFO] '`auth:store` type: verifyCodeAndSignIn'
5/28/2025, 5:01:21 PM [CONVEX M(auth:store)] [ERROR] 'Invalid verification code'
5/28/2025, 5:01:21 PM [CONVEX A(auth:signIn)] Uncaught Error: Could not verify code
at handleEmailAndPhoneProvider (../../node_modules/@convex-dev/auth/src/server/implementation/signIn.ts:116:4)
at async handler (../../node_modules/@convex-dev/auth/src/server/implementation/index.ts:416:10)
5/28/2025, 5:00:24 PM [CONVEX M(auth:store)] [INFO] '`auth:store` type: createVerificationCode'
5/28/2025, 5:00:48 PM [CONVEX M(auth:store)] [INFO] '`auth:store` type: verifyCodeAndSignIn'
5/28/2025, 5:01:21 PM [CONVEX M(auth:store)] [INFO] '`auth:store` type: verifyCodeAndSignIn'
5/28/2025, 5:01:21 PM [CONVEX M(auth:store)] [ERROR] 'Invalid verification code'
5/28/2025, 5:01:21 PM [CONVEX A(auth:signIn)] Uncaught Error: Could not verify code
at handleEmailAndPhoneProvider (../../node_modules/@convex-dev/auth/src/server/implementation/signIn.ts:116:4)
at async handler (../../node_modules/@convex-dev/auth/src/server/implementation/index.ts:416:10)
Here is the SignIn.tsx for reference
import { useAuthActions } from '@convex-dev/auth/react';
import { useState } from 'react';
import {
Button,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';

export function SignIn() {
const { signIn } = useAuthActions();
const [step, setStep] = useState<'signIn' | { email: string }>('signIn');
const [email, setEmail] = useState('');
const [code, setCode] = useState('');

const handleEmailSubmit = () => {
const formData = new FormData();
formData.append('email', email);
void signIn('resend-otp', formData).then(() => setStep({ email }));
};

const handleCodeSubmit = () => {
if (typeof step === 'object' && 'email' in step) {
const formData = new FormData();
formData.append('email', step.email);
formData.append('code', code);
void signIn('resend-otp', formData);
}
};

return (
<View style={styles.container}>
{step === 'signIn' ? (
<>
<TextInput
value={email}
onChangeText={setEmail}
placeholder='Email'
autoCapitalize='none'
keyboardType='email-address'
style={styles.input}
/>
<Button title='Send code' onPress={handleEmailSubmit} />
</>
) : (
<>
<TextInput
value={code}
onChangeText={setCode}
placeholder='Code'
keyboardType='number-pad'
style={styles.input}
/>
<Button title='Continue' onPress={handleCodeSubmit} />
<TouchableOpacity onPress={() => setStep('signIn')}>
<Text style={styles.cancel}>Cancel</Text>
</TouchableOpacity>
</>
)}
</View>
);
}
import { useAuthActions } from '@convex-dev/auth/react';
import { useState } from 'react';
import {
Button,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';

export function SignIn() {
const { signIn } = useAuthActions();
const [step, setStep] = useState<'signIn' | { email: string }>('signIn');
const [email, setEmail] = useState('');
const [code, setCode] = useState('');

const handleEmailSubmit = () => {
const formData = new FormData();
formData.append('email', email);
void signIn('resend-otp', formData).then(() => setStep({ email }));
};

const handleCodeSubmit = () => {
if (typeof step === 'object' && 'email' in step) {
const formData = new FormData();
formData.append('email', step.email);
formData.append('code', code);
void signIn('resend-otp', formData);
}
};

return (
<View style={styles.container}>
{step === 'signIn' ? (
<>
<TextInput
value={email}
onChangeText={setEmail}
placeholder='Email'
autoCapitalize='none'
keyboardType='email-address'
style={styles.input}
/>
<Button title='Send code' onPress={handleEmailSubmit} />
</>
) : (
<>
<TextInput
value={code}
onChangeText={setCode}
placeholder='Code'
keyboardType='number-pad'
style={styles.input}
/>
<Button title='Continue' onPress={handleCodeSubmit} />
<TouchableOpacity onPress={() => setStep('signIn')}>
<Text style={styles.cancel}>Cancel</Text>
</TouchableOpacity>
</>
)}
</View>
);
}
Pretty sure I got this part cleared up. Im trying to just retrieve data about the logged in user from the users table
import { query } from "./_generated/server";

export const getCurrentUser = query(async (ctx) => {
const identity = await ctx.auth.getUserIdentity();

if (!identity) return null;

return {
id: identity._id,
name: identity.name,
email: identity.email,
};
});
import { query } from "./_generated/server";

export const getCurrentUser = query(async (ctx) => {
const identity = await ctx.auth.getUserIdentity();

if (!identity) return null;

return {
id: identity._id,
name: identity.name,
email: identity.email,
};
});
Is getUserIdentity correct in this instance?
erquhart
erquhart5d ago
Yeah that's right, but just use the _id and get the rest from the database to keep the query reactive
Justin
JustinOP5d ago
Do we also need to define the users table in the schema.ts? https://docs.convex.dev/auth/database-auth I've defined it based on this and modified the function to this
import { query } from "./_generated/server";

export const getCurrentUser = query(async (ctx) => {
const identity = await ctx.auth.getUserIdentity();

if (!identity) {
throw new Error("Called storeUser without authentication present");
}

const user = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("tokenIdentifier"), identity.tokenIdentifier))
.unique();

return {
id: user?._id,
name: user?.name,
email: user?.email,
};
});
import { query } from "./_generated/server";

export const getCurrentUser = query(async (ctx) => {
const identity = await ctx.auth.getUserIdentity();

if (!identity) {
throw new Error("Called storeUser without authentication present");
}

const user = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("tokenIdentifier"), identity.tokenIdentifier))
.unique();

return {
id: user?._id,
name: user?.name,
email: user?.email,
};
});
But it seems this way you have to store the tokenIdentifier or you can't query a user Got this one working!
import { getAuthUserId } from "@convex-dev/auth/server";
import { query } from "./_generated/server";

export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);

if (!userId) {
return null;
}

const user = await ctx.db.get(userId);
if (!user) {
return null;
}

return {
id: user._id,
name: user.name,
email: user.email,
};
},
});
import { getAuthUserId } from "@convex-dev/auth/server";
import { query } from "./_generated/server";

export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);

if (!userId) {
return null;
}

const user = await ctx.db.get(userId);
if (!user) {
return null;
}

return {
id: user._id,
name: user.name,
email: user.email,
};
},
});
When you mention to keep it lean, since the _id shouldnt change once a user registers what if we do a hook to get the user
const userId = await getAuthUserId(ctx);
const userId = await getAuthUserId(ctx);
Then cache that userId with a useQuery and use it for future functions? or should we just call it each time in the function? It seems to be relatively inexpensive.
erquhart
erquhart5d ago
You want to call it each time in case the authorization changes
Justin
JustinOP4d ago
What would you recommend when writing functions to retrieve data for a user, should we store the _id in those records, lets say its a list of ToDo's, to retrieve them for the authenticated user would you use that id or is there a better way fetch data?
erquhart
erquhart3d ago
That's exactly how you'd do it 👍

Did you find this page helpful?