← All posts
·6 min read·GuardLayer

app_metadata vs user_metadata in Supabase RLS

SupabaseRLSAuthRBAC

user_metadata can be updated by the logged-in user with a single supabase.auth.updateUser() call, so any role or permission stored there is self-service. Authorization claims belong in app_metadata, which only your server (service_role) or an Auth Hook can write.

Both fields land in the user's JWT. Both are readable from an RLS policy with auth.jwt(). They look interchangeable in the token payload, and that's precisely the problem — one of them is attacker-controlled.

Can a user edit their own user_metadata in Supabase?

Yes. user_metadata is writable by the authenticated user themselves. The Supabase docs state the distinction plainly:

raw_user_meta_data can be updated by the authenticated user using the supabase.auth.update() function and is not a good place to store authorization data. raw_app_meta_data cannot be updated by the user, so it's a good place to store authorization data.

Supabase docs, Row Level Security

user_metadata exists for user-owned preferences: display name, avatar URL, theme, locale. It is populated from whatever you pass as options.data at sign-up, which is already a hint — that value came from the client.

user_metadataapp_metadata
Written byThe user, via updateUser()Server only (service_role / Auth Hook)
Set at sign-up from client inputYesNo
In the JWTYesYes
Safe for roles & permissionsNoYes
Intended forPreferences, profile displayAuthorization, tenancy, plan

The escalation, end to end

Here's the policy that ships. It reads a role out of the token, which feels rigorous — the check is in the database, enforced by Postgres:

create policy "admins manage billing"
  on public.billing_accounts
  for all
  to authenticated
  using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin');

Now the attack, which takes one line in the browser console of any signed-in user:

await supabase.auth.updateUser({ data: { role: "admin" } });

data writes user_metadata. The call succeeds — it's a legitimate API doing exactly what it's designed to do. The user's next token carries "role": "admin", the policy evaluates true, and they own the billing table. No exploit, no CVE, no unusual traffic. Just the documented SDK.

The same pattern shows up in app-layer code, where it's just as wrong:

// Any user can make this true for themselves.
const { data: { user } } = await supabase.auth.getUser();
if (user?.user_metadata?.role === "admin") {
  return renderAdminDashboard();
}

getUser() is the right call here — it revalidates against the Auth server, unlike getSession(), which doesn't verify anything on the server. But verifying the token doesn't help when the claim inside it is user-writable. The token is authentic; the data in it was set by the person you're trying to authorize.

How do I store a role so users can't change it?

Two supported options, depending on how fresh the role needs to be.

Option 1 — app_metadata, written server-side. Only the Admin API (service_role) can set it:

import "server-only";
import { createClient } from "@supabase/supabase-js";

// service_role — server only, never NEXT_PUBLIC_.
const admin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function promoteToAdmin(userId: string) {
  await admin.auth.admin.updateUserById(userId, {
    app_metadata: { role: "admin" },
  });
}

This key bypasses every RLS policy you have, so it must never reach the browser — that's its own class of breach. Keep it in a server module and guard the promotion itself with a real authorization check.

Then read the safe claim in your policy:

create policy "admins manage billing"
  on public.billing_accounts
  for all
  to authenticated
  using (((select auth.jwt()) -> 'app_metadata' ->> 'role') = 'admin');

Option 2 — a roles table, joined in the policy. Slower per query, but always current:

create table public.user_roles (
  user_id uuid references auth.users on delete cascade,
  role text not null,
  primary key (user_id, role)
);

alter table public.user_roles enable row level security;

create policy "admins manage billing"
  on public.billing_accounts
  for all
  to authenticated
  using (
    exists (
      select 1 from public.user_roles
      where user_id = (select auth.uid()) and role = 'admin'
    )
  );

Note that user_roles needs RLS of its own, and no policy that lets users write it. A roles table anyone can insert into is the same escalation with extra steps.

Which one should you pick?

app_metadata is faster — the claim is already in the token, so there's no join. The cost is staleness. A JWT keeps its claims until it refreshes, so a demotion doesn't take effect immediately:

Keep in mind that a JWT is not always fresh.

Supabase docs, Row Level Security

For roles that rarely change (plan tier, tenant membership), app_metadata is the right default. For revocation that must be instant — firing an admin, banning an account — use the table, or shorten your JWT expiry and force a refresh.

If you want claims that update on every token issue without hand-rolling it, Supabase's Custom Access Token Auth Hook runs before a token is minted and can inject claims from your own tables — the roles-table source of truth with the JWT's read performance.

A 30-second self-check

# 1. Any RLS policy reading authorization out of user_metadata
grep -rn "user_metadata" supabase/migrations/

# 2. App-layer gates trusting user_metadata
grep -rn "user_metadata" app/ lib/ --include="*.ts" --include="*.tsx"

# 3. Confirm the safe field is what your policies read
grep -rn "app_metadata" supabase/migrations/

Any hit from the first two that decides permissions rather than rendering a display name is an escalation path. Note that this one is invisible to grep alone if the claim is aliased through a helper function — check what your authorize() or is_admin() SQL functions actually read.

Worth being precise about scope: GuardLayer's static rules flag unscoped and permissive policies, but a policy reading user_metadata is syntactically well-formed and does reference auth.jwt(), so it won't trip the user-scoping rule. This one is a review item, not a scan finding — which is exactly why it survives to production so often.

FAQ

Is it safe to store a role in user_metadata if I only read it on the server? No. Where you read it doesn't matter — the user can write it. A server-side check against user_metadata is still a check against attacker-supplied data.

What is user_metadata actually for? Preferences and profile display: full name, avatar URL, locale, theme. Anything where the user changing it affects only their own experience.

Can a user modify app_metadata? Not through the normal client SDK. It's writable via the Admin API with the service_role key, or from an Auth Hook. Keep that key server-side.

How do I get app_metadata into an existing user's token? Set it with auth.admin.updateUserById(), then refresh the session. The claim appears on the next issued token, not the current one.

Does auth.jwt() read app_metadata directly? Yes — auth.jwt() -> 'app_metadata' ->> 'role'. Wrap it as (select auth.jwt()) in policies so Postgres evaluates it once per query rather than once per row.

Catch this before it ships — free

GuardLayer scans every push for this and 26 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading