Skip to content
Moteeb Asadimoteeb.asad@gmail.com

Blog

Don't store roles in Supabase user_metadata

My Next.js middleware read user roles from Supabase user_metadata. Any logged-in user can edit their own user_metadata, so anyone could make themselves an admin from the browser console. How I moved roles to app_metadata and switched to permission checks.

3 min read#supabase#nextjs#security#typescript

In my Next.js app, I stored user roles in Supabase user_metadata. The middleware read them to decide which pages a user could open.

That was a security hole.

The problem

Users can edit their own user_metadata. It's meant for things like a display name or an avatar, so Supabase lets the signed-in user update it.

That means any logged-in user could change their role from the browser console:

await supabase.auth.updateUser({ data: { roles: ["super_admin"] } });

After their token refreshed, the middleware read the new role and let them open admin pages.

This was the check:

middleware.ts (before)
// user_metadata can be edited by the user themselves
const roles = claims?.user_metadata?.roles;
const isAllowed = roles.some((r) => allowedRoles.includes(r));

The fix: app_metadata

app_metadata can only be written by the server, with the service role key. A user can't change it from the browser.

So I moved roles there:

  • A database trigger updates app_metadata whenever an admin changes someone's role. The role is still stored in my own table; the trigger copies it into the token data.
  • The middleware reads the role from app_metadata only.

Check permissions, not role names

While I was in there, I changed what the middleware checks. Instead of asking "is this user an admin?", each route needs a permission, and each role has a list of permissions.

middleware.ts (after)
// app_metadata can only be written by the server
const role = getRoleFromAppMetadata(claims?.app_metadata);
const permission = getRoutePermission(pathname);
 
if (permission && !hasPermission(role, permission)) {
  return NextResponse.redirect(new URL("/login?unauthorized=1", request.url));
}

The permissions live in one typed file:

rbac.ts
export const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
  super_admin: PERMISSIONS,
  operations_manager: PERMISSIONS.filter((p) => p !== "users:manage"),
  store_keeper: [
    "dashboard:view", "receiving:view",
    "receiving:create", "settings:view",
  ],
};

Because Role and Permission are types, a typo like "recieving:view" fails to compile instead of silently denying access. Adding a page means adding one permission, not finding every place that checks a role name.

The middleware is not the only check

The middleware decides which pages someone can open. It is not the security boundary for data.

Admin actions still check the role in the database before they change anything, and Row Level Security still decides which rows each user can read. If the middleware is ever wrong, the data stays protected.

The trade-off

Role changes don't apply instantly. The role travels inside the user's token, so a change applies when that token refreshes. In Supabase that happens on its own, by default within an hour.

For my app that's acceptable. If you need a role removal to take effect right away, you also need to sign the user out or revoke their session.

Check your own app

Search for every place you read user_metadata:

grep -rn "user_metadata" src

Anything used for access, like roles, plans or permissions, should come from app_metadata or your database instead. Keep user_metadata for things the user is allowed to change, like their name.

All posts