Policy for ADMIN/SUPER_ADMIN access returning empty results

Hi everyone, I’m trying to create an RLS policy that only allows ADMIN and SUPER_ADMIN users (based on their app_metadata.role in the JWT) to view rows in my user_roles table. Here’s what I currently have: alter policy "Admins can view user_roles" on "public"."user_roles" for select to authenticated using ( ((auth.jwt() -> 'app_metadata'::text) ->> 'role'::text) = ANY (ARRAY['ADMIN'::text, 'SUPER_ADMIN'::text]) ); The role field is definitely inside app_metadata in the JWT, but when I test this, even admin users get back an empty result set. Am i missing something?
10 Replies
garyaustin
garyaustin2mo ago
Can you show an example of your app_metadata? Try just comparing to a single value. Also if using next.js and changing RLS behind the scenes caching may not reflect the changes if you ran the operation before.
Gabriel Damian
Gabriel DamianOP2mo ago
I have this user who currently has the admin role.
No description
garyaustin
garyaustin2mo ago
Also try it from the sql editor with impersonation
Gabriel Damian
Gabriel DamianOP2mo ago
Okay. I'll try that. For added context, I'm using the following functions in my code
ts
function getSupabaseHeaders(): Record<string, string> {
return {
"Content-Type": "application/json",
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
};
}

export async function fetchFromDb<T>(
endpoint: string,
revalidate: number = 3600,
tags: string[] = [],
timeout: number = 10000
): Promise<T> {
if (!endpoint || typeof endpoint !== "string") {
throw new Error("Invalid endpoint provided");
}

if (timeout <= 0) {
throw new Error("Timeout must be a positive number");
}

const fullUrl = `${SUPABASE_URL}/${endpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

try {
const response = await fetch(fullUrl, {
headers: getSupabaseHeaders(),
signal: controller.signal,
next: {
revalidate,
tags,
},
});

clearTimeout(timeoutId);

if (!response.ok) {
throw new FetchHttpError(response.status, response.statusText, endpoint);
}

try {
return await response.json();
} catch (jsonError) {
const message = jsonError instanceof Error ? jsonError.message : "Unknown JSON parsing error";
throw new FetchJsonError(message, endpoint);
}
} catch (error) {
clearTimeout(timeoutId);

if (error instanceof DOMException && error.name === "AbortError") {
throw new FetchTimeoutError(timeout);
}

if (error instanceof TypeError && error.message.includes("Failed to fetch")) {
throw new FetchNetworkError(endpoint, error.message);
}
if (error instanceof FetchHttpError || error instanceof FetchJsonError) {
throw error;
}
const message = error instanceof Error ? error.message : "Unknown error";
throw new Error(`Unexpected error fetching ${endpoint}: ${message}`);
}
}
ts
function getSupabaseHeaders(): Record<string, string> {
return {
"Content-Type": "application/json",
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
};
}

export async function fetchFromDb<T>(
endpoint: string,
revalidate: number = 3600,
tags: string[] = [],
timeout: number = 10000
): Promise<T> {
if (!endpoint || typeof endpoint !== "string") {
throw new Error("Invalid endpoint provided");
}

if (timeout <= 0) {
throw new Error("Timeout must be a positive number");
}

const fullUrl = `${SUPABASE_URL}/${endpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);

try {
const response = await fetch(fullUrl, {
headers: getSupabaseHeaders(),
signal: controller.signal,
next: {
revalidate,
tags,
},
});

clearTimeout(timeoutId);

if (!response.ok) {
throw new FetchHttpError(response.status, response.statusText, endpoint);
}

try {
return await response.json();
} catch (jsonError) {
const message = jsonError instanceof Error ? jsonError.message : "Unknown JSON parsing error";
throw new FetchJsonError(message, endpoint);
}
} catch (error) {
clearTimeout(timeoutId);

if (error instanceof DOMException && error.name === "AbortError") {
throw new FetchTimeoutError(timeout);
}

if (error instanceof TypeError && error.message.includes("Failed to fetch")) {
throw new FetchNetworkError(endpoint, error.message);
}
if (error instanceof FetchHttpError || error instanceof FetchJsonError) {
throw error;
}
const message = error instanceof Error ? error.message : "Unknown error";
throw new Error(`Unexpected error fetching ${endpoint}: ${message}`);
}
}
could it be an issue with the type of auth keys i'm using?
Lordvickthor
Lordvickthor2mo ago
Have you been able to resolve this @Gabriel Damian ?
Gabriel Damian
Gabriel DamianOP2mo ago
Not yet. I'm thinking its an issue with the headers I'm using (anon key). I'm refraining from using the service role key because I believe it should work with the anon key. I'm just not sure
garyaustin
garyaustin2mo ago
Did you try the SQL editor to rule out your app? Simplify the RLS to just TO authenticated and True to see if you can access it at all. Check the API Gateway log for the call and you can see the user info making the call including the role (should be authenticated). Anon key is what you want to use for the apikey otherwise you have to be serverside only to use service_role to bypass all RLS.
muyiwa Johnson
muyiwa Johnson2mo ago
Yeah I think it's because above in the pasted text, it's set to anon , I mean the getSupabaeHeaders function headers However the RLS is set to authenticated. So it looks like you need an authenticated access token allow access to that table But the API docs requests for anon key
garyaustin
garyaustin2mo ago
The apikey is anon. Your authentication header is the user JWT.
muyiwa Johnson
muyiwa Johnson2mo ago
this works as expected!
No description

Did you find this page helpful?