Skip to content

Guide · Supabase

Roles and permissions for Supabase apps

Row Level Security is a great lock for “users see their own rows”. When you need workspaces, roles per project and sharing, put those decisions in Permio and check them from an Edge Function.

Uses Supabase Auth, Postgres and Edge Functions, with the permio TypeScript SDK. Updated November 2026.

Where Row Level Security stops

Supabase lets the browser talk to Postgres directly, and Row Level Security (RLS) policies decide which rows each request can touch. For data that belongs to one user, a policy like using (auth.uid() = user_id) is all you need, and you should keep using it.

It gets harder when access depends on more than ownership. Workspaces with members, a viewer who can read but not edit, one project shared with an outside collaborator: each needs a membership table, policies that join it, and care that every table and every operation is covered. Nested data (a task inside a project inside a workspace) repeats that logic at every level, and “who can see this project?” has no direct answer. The failure mode is quiet: RLS left switched off, or a policy broader than intended, lets everyone in. That’s why so many generated Supabase apps ship with RLS off, or with policies like using (true).

With Permio, the membership table and its policies go away. You grant a role on a path such as /workspaces/42, and it applies to every project in that workspace, including ones created later. The database keeps one simple rule: the browser can’t reach shared tables directly.

How it fits together

The browser calls an Edge Function with the user’s Supabase session. The function reads the user ID from the session token, asks Permio whether that user may do the action on that resource, and only then reads or writes Postgres with a secret (service role) key. RLS stays on, with no policies for the browser on these tables, so the only way in is through your functions.

The example is a team app: workspaces that contain projects. Resource URIs follow that shape, /workspaces/{workspaceId} and /workspaces/{workspaceId}/projects/{projectId}, so a role on a workspace covers all of its projects, and a role on one project covers only that project.

1. Set up Permio

Sign up and use the default project. A Permio project is a container for your permissions, roles and grants, not one of the example app’s projects. In the dashboard, create these permissions:

Permissions for the example app
PermissionLets the user
project.readOpen a project and see it in lists
project.writeRename or edit a project
workspace.manageCreate projects, invite people, change roles

Then create three roles that bundle them:

Roles for the example app
RolePermissions
viewerproject.read
editorproject.read,project.write
ownerproject.read,project.write,workspace.manage

Grants refer to roles by ID; the code below looks the IDs up by name, so there’s nothing to copy. Open API Keys and create a full-access key, because the app grants roles as well as checking them. Copy it now; it’s only shown once. See API keys for check-only keys and key hygiene.

2. Store the key as an Edge Function secret

Put the API key in supabase/functions/.env, which Edge Functions read locally, and keep that file out of git. Then push the same file to your project, so the key never sits in your shell history.

supabase/functions/.env

PERMIO_API_KEY=perms_your_key
supabase secrets set --env-file supabase/functions/.env

3. Add a shared helper

One file creates the Permio client and a Supabase client with your project’s secret key, works out who is calling, validates input and handles CORS so the browser can call your functions. Folders that start with an underscore are shared code, not functions.

supabase/functions/_shared/permio.ts

import { Permio } from "npm:permio@^0.5.0";
import { createClient } from "npm:@supabase/supabase-js@^2";

// "default" is the Permio project that holds your grants, not one of your app's projects.
export const permio = new Permio({
  apiKey: Deno.env.get("PERMIO_API_KEY")!,
  project: "default",
});

// Grants take role IDs. Look them up by name once per function instance.
let roleIds: Map<string, string> | undefined;
export async function roleId(name: string): Promise<string> {
  if (!roleIds) {
    const found = new Map<string, string>();
    for await (const role of permio.listRoles()) found.set(role.name, role.id);
    roleIds = found;
  }
  const id = roleIds.get(name);
  if (!id) throw new Error("No Permio role named " + name);
  return id;
}

// The secret key bypasses RLS, so only use this client after a Permio check.
// Older projects have SUPABASE_SERVICE_ROLE_KEY instead of SUPABASE_SECRET_KEYS.
const secretKeys = JSON.parse(Deno.env.get("SUPABASE_SECRET_KEYS") ?? "{}");
export const admin = createClient(
  Deno.env.get("SUPABASE_URL")!,
  secretKeys.default ?? Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};

export function json(body: unknown, status = 200): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { ...corsHeaders, "Content-Type": "application/json" },
  });
}

class BadRequest extends Error {}

// A non-empty string from the request body, or a 400.
export function text(value: unknown): string {
  if (typeof value !== "string" || value === "") throw new BadRequest();
  return value;
}

// Runs a handler with the signed-in user's ID, the Permio principal.
export function handle(fn: (userId: string, body: Record<string, unknown>) => Promise<Response>) {
  return async (req: Request): Promise<Response> => {
    if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
    const token = req.headers.get("Authorization")?.replace("Bearer ", "") ?? "";
    const { data, error } = await admin.auth.getUser(token);
    if (error || !data.user) return json({ error: "Sign in first" }, 401);
    try {
      return await fn(data.user.id, await req.json().catch(() => ({})));
    } catch (err) {
      if (err instanceof BadRequest) return json({ error: "Bad request" }, 400);
      console.error(err);
      // Still a JSON response with CORS headers, so the browser shows the real error.
      return json({ error: "Server error" }, 500);
    }
  };
}

4. Check before you touch data

Each function names the resource it touches and asks Permio. resource() builds the URI and percent-encodes the IDs, so build URIs with it everywhere.

supabase/functions/get-project/index.ts

import { resource } from "npm:permio@^0.5.0";
import { admin, handle, json, permio, text } from "../_shared/permio.ts";

Deno.serve(handle(async (userId, body) => {
  const workspaceId = text(body.workspaceId);
  const projectId = text(body.projectId);
  const allowed = await permio.check({
    principal: userId,
    resource: resource("workspaces", workspaceId, "projects", projectId),
    permission: "project.read",
  });
  if (!allowed) return json({ error: "Not allowed" }, 403);

  const { data, error } = await admin
    .from("projects")
    .select("*")
    .eq("workspace_id", workspaceId)
    .eq("id", projectId)
    .single();
  if (error) return json({ error: "Not found" }, 404);
  return json(data);
}));

A function that changes a project checks project.write instead. In the app, call the function with the Supabase client, which sends the user’s session for you:

const { data: project, error } = await supabase.functions.invoke("get-project", {
  body: { workspaceId, projectId },
});

5. Grant roles: owner on create, then invites

Whoever creates a workspace gets the owner role on it, and so on every project inside it. If the grant fails, remove the row so you never keep a workspace nobody can open.

supabase/functions/create-workspace/index.ts

import { resource } from "npm:permio@^0.5.0";
import { admin, handle, json, permio, roleId, text } from "../_shared/permio.ts";

Deno.serve(handle(async (userId, body) => {
  const { data: workspace, error } = await admin
    .from("workspaces")
    .insert({ name: text(body.name), created_by: userId })
    .select()
    .single();
  if (error) return json({ error: error.message }, 400);

  try {
    await permio.grant({
      principal: userId,
      resource: resource("workspaces", workspace.id),
      role: await roleId("owner"),
    });
  } catch (err) {
    await admin.from("workspaces").delete().eq("id", workspace.id);
    throw err;
  }
  return json(workspace, 201);
}));

Inviting a teammate is one more grant, made only by someone who holds workspace.manage. Grant on the workspace for access to everything in it, or on one project’s URI to share just that project.

supabase/functions/invite-member/index.ts

import { resource } from "npm:permio@^0.5.0";
import { handle, json, permio, roleId, text } from "../_shared/permio.ts";

Deno.serve(handle(async (userId, body) => {
  const workspace = resource("workspaces", text(body.workspaceId));
  const role = text(body.role);
  if (role !== "viewer" && role !== "editor") return json({ error: "Unknown role" }, 400);

  const allowed = await permio.check({
    principal: userId,
    resource: workspace,
    permission: "workspace.manage",
  });
  if (!allowed) return json({ error: "Not allowed" }, 403);

  await permio.grant({ principal: text(body.memberId), resource: workspace, role: await roleId(role) });
  return json({ ok: true });
}));

6. Filter lists to what the user can see

A project list shouldn’t fetch every row and then check each one. Ask Permio once with listAccess: if the user’s access covers the whole workspace, query the workspace as usual; otherwise it names the projects shared with them, which becomes an in filter.

supabase/functions/list-projects/index.ts

import { resource } from "npm:permio@^0.5.0";
import { admin, handle, json, permio, text } from "../_shared/permio.ts";

Deno.serve(handle(async (userId, body) => {
  const workspaceId = text(body.workspaceId);
  const access = await permio.listAccess({
    principal: userId,
    permission: "project.read",
    under: resource("workspaces", workspaceId, "projects"),
  });

  let query = admin.from("projects").select("*").eq("workspace_id", workspaceId);
  if (!access.coversAll) {
    // Projects shared one by one: "/workspaces/{w}/projects/{p}".
    // Grants made with a "_" wildcard aren't prefixes; they're left out (safe, not shown).
    const ids = access.toPrefixes()
      .map((uri) => uri.split("/"))
      .filter((s) => s.length === 5)
      .map((s) => decodeURIComponent(s[4]));
    if (ids.length === 0) return json([]);
    query = query.in("id", ids);
  }
  const { data } = await query.order("created_at");
  return json(data ?? []);
}));

7. Keep the browser out of these tables

Turning RLS on doesn’t remove policies that are already there, and any one policy that allows access is enough to let a request in. So first drop every existing policy on the shared tables, including generated ones like using (true), then turn RLS on with none. Run this as a migration:

-- Drop every existing policy on the shared tables.
do $$
declare p record;
begin
  for p in
    select policyname, tablename from pg_policies
    where schemaname = 'public' and tablename in ('workspaces', 'projects')
  loop
    execute format('drop policy %I on public.%I', p.policyname, p.tablename);
  end loop;
end $$;

-- RLS on with no policies: the browser's anon and authenticated roles get nothing.
alter table public.workspaces enable row level security;
alter table public.projects enable row level security;

-- Belt and braces: take away their table privileges too.
revoke all on public.workspaces, public.projects from anon, authenticated;

Check the result with select * from pg_policies where tablename in ('workspaces', 'projects'), which should return nothing. Your functions use the secret key, which bypasses RLS, so they keep working. Deploy them with supabase functions deploy. Tables that really are one-user data, like a profile, can keep ordinary RLS policies and be read straight from the browser.

Let your AI assistant do it

Building with Cursor, Claude Code, Lovable, Bolt or v0? Paste this prompt and it will make the changes above in your own code, using your data model.

Prompt
Add authorisation to this Supabase app with Permio (perms.io), using the `permio` npm package.

Read these first:
- https://perms.io/guides/supabase (how Permio fits Supabase Auth, Postgres and Edge Functions)
- https://perms.io/llms-full.txt (model, APIs and SDK usage)
- https://docs.perms.io/sdk/typescript/

Rules:
- Keep our existing login. The Permio principal is the signed-in user's ID.
- The Permio API key is a server-side secret. Never put it in client code or a public env var.
- Resource URIs follow our data model, e.g. /workspaces/{id}/projects/{id}, built with the SDK's resource() helper.
- "Project" in the Permio client options is a Permio project (a container for grants), not one of our app's projects.
- Grants take role IDs: look them up by name with permio.listRoles() once and cache them.
- A denied check is a normal answer: return 403 (or the platform's permission-denied error), not a 500.
- Before moving existing queries, show me the plan: which reads and writes move to the server and which stay.

Then:
1. Create supabase/functions/_shared/permio.ts: the Permio client from Deno.env PERMIO_API_KEY, a Supabase client with the secret key (SUPABASE_SECRET_KEYS default, falling back to SUPABASE_SERVICE_ROLE_KEY), CORS handling, input validation, a helper that reads the user ID from the Authorization header with auth.getUser(), and a try/catch that returns JSON errors with CORS headers.
2. Move reads and writes of shared data (workspaces, projects and anything inside them) into Edge Functions that call permio.check() before touching the database with the secret key.
3. When a workspace is created, grant its creator the owner role on /workspaces/{id}. Delete the row if the grant fails. Add an invite function that checks workspace.manage before granting viewer or editor.
4. Filter list functions with permio.listAccess() and coversAll / toPrefixes(), not by loading every row.
5. Write a migration that DROPS EVERY EXISTING POLICY on those tables (query pg_policies; generated "using (true)" policies are common), then enables RLS with no policies and revokes table privileges from anon and authenticated. Enabling RLS alone leaves old policies active.
6. Update the frontend to call the functions with supabase.functions.invoke().
Finally, list the permissions and roles I need to create in the Permio dashboard.

Frequently asked questions

Should I replace Row Level Security with Permio?

No. Keep RLS on every table. Use ordinary policies for data that belongs to one user. For shared data, drop the browser policies and leave RLS on, so the tables can only be reached through Edge Functions that check Permio. RLS stays the lock; Permio decides who holds the key.

Can I call Permio from inside an RLS policy?

No. Policies run inside Postgres and can’t make HTTP calls to Permio. Check in an Edge Function (or any server code) before querying with the secret key.

What is the principal ID for a Supabase user?

The user’s id from Supabase Auth (auth.users.id, a UUID). Permio doesn’t need to know about users in advance; the first grant to an ID is enough. To invite someone who hasn’t signed up yet, store the invite and make the grant when they first sign in.

Does the permio package work in Supabase Edge Functions?

Yes. Edge Functions run on Deno, which loads the package with an npm: specifier. The SDK only needs fetch, so it works in Deno, Node and edge runtimes.

Does this work with Lovable or Bolt?

Yes, when the app uses Supabase as its backend, which Lovable does and Bolt can. Paste the prompt on this page into the assistant to have it make the changes.

Try it on your own app. The free plan needs no payment details, and the quickstart gets you to a passing check in a few minutes.

Further reading