Code audit

AI-Generated Team Management: 6 Problems in 90 Lines

14 min

AI-generated role management: line by line audit

The context

Next in the series "what AI gets wrong when you vibe code your SaaS". The previous episode was about a Stripe checkout. Today, we go up a notch in critical: the Team page.

You know, the admin page where you invite teammates, change their role, revoke invitations. On most SaaS, it looks simple. A form, a table, two dropdowns. The AI generates that in 30 seconds.

The problem: it's also the page that decides who can do what in your tool. And a bug here doesn't cost you money, it costs you control.

I got my hands on a Team page generated by an AI agent for a CRM. 90 lines of business logic (without the JSX). Six problems. One of them lets you wipe out every admin from the workspace by opening DevTools.

Let's go.

If you don't read code yourself, no worries: skip the code blocks and just read the section titles and the "What you should do instead" paragraphs. The rule that sums it all up is at the bottom.

The code in question

// Imports and JSX omitted. We're only looking at the business logic.

interface Member { id: string; full_name: string | null; email: string; role: "admin" | "rep" }
interface Invite { id: string; email: string; role: "admin" | "rep"; token: string; expires_at: string; accepted_at: string | null }

export default function Team() {
  const { company, user } = useAuth();
  const [members, setMembers] = useState<Member[]>([]);
  const [invites, setInvites] = useState<Invite[]>([]);
  const [email, setEmail] = useState("");
  const [role, setRole] = useState<"admin" | "rep">("rep");

  const load = async () => {
    if (!company) return;
    const [profilesRes, rolesRes, invitesRes] = await Promise.all([
      supabase.from("profiles").select("id, full_name, email").eq("company_id", company.id),
      supabase.from("user_roles").select("user_id, role").eq("company_id", company.id),
      supabase
        .from("invitations")
        .select("id, email, role, token, expires_at, accepted_at")
        .eq("company_id", company.id)
        .is("accepted_at", null),
    ]);
    // ... updates state
  };

  const invite = async () => {
    if (!company) return;
    const parsed = z.string().trim().email().max(255).safeParse(email);
    if (!parsed.success) return toast.error("Valid email required");
    const { data, error } = await supabase
      .from("invitations")
      .insert({ company_id: company.id, email: parsed.data, role, invited_by: user?.id ?? null })
      .select()
      .single();
    if (error) return toast.error(error.message);
    const link = `${window.location.origin}/auth/signup?invite=${data.token}`;
    await navigator.clipboard.writeText(link).catch(() => {});
    toast.success("Invite created — link copied to clipboard");
  };

  const revoke = async (id: string) => {
    const { error } = await supabase.from("invitations").delete().eq("id", id);
    if (error) return toast.error(error.message);
    load();
  };

  const changeRole = async (userId: string, newRole: "admin" | "rep") => {
    if (!company) return;
    if (userId === user?.id && newRole === "rep") {
      const adminCount = members.filter((m) => m.role === "admin").length;
      if (adminCount <= 1) return toast.error("At least one admin required");
    }
    await supabase.from("user_roles").delete().eq("user_id", userId).eq("company_id", company.id);
    const { error } = await supabase.from("user_roles").insert({ user_id: userId, company_id: company.id, role: newRole });
    if (error) return toast.error(error.message);
    toast.success("Role updated");
    load();
  };

  // ... return JSX with the form and the table
}

At first glance, it's clean. Clean hooks, email validation with zod, error handling via toast, the code is readable. A junior dev signs that in review. So does an AI agent.

Now let's look at what's actually happening.

Problem 1: the "at least one admin" check runs in the browser (very serious)

This is the most telling piece in the file:

const changeRole = async (userId, newRole) => {
  if (userId === user?.id && newRole === "rep") {
    const adminCount = members.filter((m) => m.role === "admin").length;
    if (adminCount <= 1) return toast.error("At least one admin required");
  }
  await supabase.from("user_roles").delete()...
  await supabase.from("user_roles").insert(...);
};

See the "at least one admin required" guard? It's in the browser's JavaScript. The if, the members.filter, the toast.error, all of it runs on the user's machine. And right below, the code calls Supabase directly to do the DELETE and the INSERT.

So:

  1. I open the browser console (F12).
  2. I type: await supabase.from('user_roles').delete().eq('company_id', myCompanyId).
  3. Every role in the workspace gets deleted. No more admins.
  4. No one can invite anymore, no one can change a role, no one can access admin pages. The workspace is dead.

An error toast is not protection. It's just a polite message. Real protection lives on the server, or it doesn't exist.

What you should do instead: the rule "there must always be at least one admin per company" is a business rule. It lives in a server-side function (your dedicated backend, or a Postgres trigger), which is the only thing allowed to modify user_roles. The browser cannot write to that table directly.

Stricter variant: a Postgres BEFORE DELETE ON user_roles trigger that refuses the delete if it would leave the company without an admin. That way, no matter where the request comes from, the database refuses.

The underlying problem behind this specific bug: your React component talks directly to Postgres. As long as that's true, any business rule you put in the browser can be bypassed, and you'll reproduce the same bug on every sensitive page. The real fix isn't local, it's architectural: you need a layer between the frontend and the database. A dedicated backend, or an API route if you're on Next.js, the form doesn't matter, but something whose job is to receive intents from the client ("change X's role to Y"), check who's calling and whether they're allowed, apply the business rules, then execute against the database. The browser shouldn't even know the user_roles table exists.

This separation looks heavy when you're vibe coding an app in 20 minutes. But that's exactly what separates a prototype from a product. The day you have ten rules around roles, invitations and billing, either they live in a server layer you can test and evolve, or they'll be scattered across React components that will eventually contradict each other.

Problem 2: changeRole is not atomic (serious)

Still inside changeRole:

await supabase.from("user_roles").delete().eq("user_id", userId)...
const { error } = await supabase.from("user_roles").insert({ user_id: userId, role: newRole });

Two separate operations. DELETE then INSERT. No transaction.

What happens if the INSERT fails (network drop, DB constraint violated, RLS refusing)? The user loses their role permanently. The DELETE went through, the INSERT didn't, and the code stops on a toast.error. Nobody cleans up. The user sits in the database without any role.

Concrete case: an admin tries to change a coworker's role. The INSERT request fails (reason X). The coworker loses access. The admin thinks nothing happened and doesn't retry right away. The coworker can't log into anything until someone fixes the DB by hand.

More subtle: between the DELETE and the INSERT, there's a race window of a few milliseconds during which the user has no role. If a request hits during that instant and reads user_roles, it sees zero roles. If your RLS logic depends on a role being present, the user might get briefly blocked (or might slip through a request they shouldn't).

What you should do instead: a single atomic operation. Either UPSERT with a unique constraint on (user_id, company_id), or a Postgres RPC function that does both inside a transaction. One unit, succeeds or fails together.

Problem 3: invite writes directly to the database from the client

const { data, error } = await supabase
  .from("invitations")
  .insert({ company_id: company.id, email: parsed.data, role, invited_by: user?.id ?? null })
  .select()
  .single();

The client sends a direct INSERT to the invitations table. The company_id, the role, the email, all of it comes from the browser. Once again, the whole defense rests on Supabase RLS to prevent abuse.

If the RLS allow an authenticated user to insert into invitations (which is the default config when you say "admins can invite"), a user can, from the console:

  • Invite themselves as admin to any company whose id they know.
  • Invite a throwaway email they control, setting the role to "admin" and pointing at another company's company_id.

The actual defense is an RLS that says: "A user can insert into invitations ONLY if their user_id shows up in user_roles with role 'admin' for that exact company_id". It's doable, but rarely well-written on the first pass.

What you should do instead: route through a dedicated backend endpoint. The client calls inviteMember({ email, role }), the server reads the identity from the session, verifies the user is admin of THEIR company, performs the INSERT itself, and returns just an OK. No more sensitive fields floating around in the request body.

Problem 4: the invitation token is stored in plaintext in the database (serious)

const { data, error } = await supabase.from("invitations").insert({ ... }).select().single();
const link = `${window.location.origin}/auth/signup?invite=${data.token}`;

The token is generated, stored in the invitations table, then read back as-is to build the invitation link. So in the database, it's plaintext.

Consequence: if an attacker gets read access to that table (stolen backup, overly broad employee access, SQL injection elsewhere in the code), they pull every unconsumed token. Each token is a valid invitation to the corresponding company, with the chosen role. A single SELECT token, company_id, role FROM invitations WHERE accepted_at IS NULL gives them a way in per pending invitation.

What you should do instead: treat the token like a password. The server generates it, stores only the hash in the database, and returns the raw value to the client ONCE (at creation time). When the invitee uses their link, the server re-hashes the received token and compares it to the stored hash. The raw value never exists anywhere retrievable.

Bonus defense-in-depth (not P0)

Once the hash is in place, a few minor leaks remain because the raw token still travels through the URL:

  • Hosting provider logs (Vercel, Netlify, Cloudflare): the full URL is logged, so the token appears in logs accessible to operations and provider support staff. Typical retention: 30 days.
  • Analytics: if you have GA or Plausible on your signup page, query params are captured by default. Worth checking case by case — often the signup page has no tracking, precisely for this reason.
  • Browser history and Referer: real but low-impact risk if you invalidate the token as soon as it's consumed. Modern browsers also default to Referrer-Policy: strict-origin-when-cross-origin, which strips the query string on cross-origin requests.

For these three vectors, the clean fix is to put the token in the URL fragment (#invite=...) instead of a query param. Fragments are never sent to the server or in the Referer. It's good hygiene, worth doing if you want to tighten defense in depth, but it's not what saves you from a serious incident. The hash in the database, that one does.

Problem 5: revoke doesn't check scope

const revoke = async (id: string) => {
  const { error } = await supabase.from("invitations").delete().eq("id", id);
  if (error) return toast.error(error.message);
  load();
};

The DELETE filters only on the id of the invitation. No filter on company_id. If you can guess or intercept an invitation id (often a UUID, but still), and the RLS aren't perfectly scoped, you can delete an invitation from another company.

Not catastrophic by itself (you don't grant access, you remove an invite). But it illustrates the dangerous pattern: trusting an id without scoping it to the current user's company.

What you should do instead: always filter by both: eq("id", id).eq("company_id", company.id). At minimum, if the RLS is misconfigured, the application code isn't doing the attacker's job for them.

Problem 6: the anti-self-demote check has a hole

Look again at the guard:

if (userId === user?.id && newRole === "rep") {
  const adminCount = members.filter((m) => m.role === "admin").length;
  if (adminCount <= 1) return toast.error("At least one admin required");
}

This guard fires only if I'm demoting myself. So:

  • Admin A and Admin B are the only two admins.
  • Admin A clicks Admin B's dropdown and switches them to "rep". No guard, it goes through.
  • Now there's only Admin A.
  • Admin A demotes themselves. The guard kicks in: "at least one admin required". Stop.
  • But Admin B is already demoted. The workspace ends up with one admin because Admin A made two clicks.

Not an attack, just broken logic. The "keep at least one admin" rule has to apply to every action, not just self-targeting. And ideally, as said in problem 1, it doesn't live in the browser.

Bonus: what doesn't break security, but is annoying

  • navigator.clipboard.writeText(...).catch(() => {}): if the copy fails, silent failure. The toast says "link copied", but nothing is on the clipboard. The user sends an empty email to their coworker.
  • (profilesRes.data ?? []) as any[]: a cast to any to mask a typing issue. When an any cast shows up, it's almost always hiding a real problem underneath.
  • No pagination on members: for a 5-person team, fine. For 1000 reps, it loads everything at once. Not critical, but it'll bite eventually.

The rule that sums it up

This file is a textbook case for one simple rule:

A client-side check is UX. A server-side check is security. Never confuse the two.

The toast.error("At least one admin required") is UX: it's nice to tell the user "no, you can't do that" instead of letting them try. But if the user has even a little malice (or the right browser extension), they bypass it in 5 seconds. Real protection lives in the database: a SQL trigger, an RLS that refuses, a server-side function that validates.

In this file, almost every "protection" is actually an error message in disguise. The whole security silently rests on Supabase RLS that you don't see in the code. It's exactly the kind of stack that passes the demo and breaks the day someone motivated comes through.

What an audit would do on this file

The audit I'd run here:

  1. Read every DB write from the client. List every .insert, .update, .delete executed from React. For each, ask: "If I call it directly from the console with arbitrary arguments, what stops me?". The answer "nothing" should be unacceptable.
  2. Read the Supabase RLS. Every table the client touches has a policy. Does it scope by company? By role? Does it block sensitive writes?
  3. Identify business rules that have to live server-side. "At least one admin", "one role per user per company", "no self-promotion", etc. For each: does it live somewhere the client can't touch?
  4. Replace direct .insert calls with backend endpoints. For sensitive operations (invite, change role, revoke), route through a server function that validates and executes. The client calls, doesn't decide.
  5. Prioritized fix plan. Workspace lockout in P0, plaintext tokens in P0, the rest in P1.

For this file, of the 6 problems, 4 disappear the moment you move the writes to your backend.

AI is not the enemy

The component above is readable. Properly typed. The form is validated with zod. The code AI generates looks like decent junior code.

The thing is, a junior dev who's competent in React isn't a junior dev who's competent in security. And AI, today, is mostly doing React. It generates code that looks good. It doesn't generate code that resists.

If you're building a multi-tenant SaaS, the "Team", "Settings", and "Billing" pages are the spots where the quietest mistake costs you the most. They're exactly the pages that deserve a human review.


Building a multi-tenant SaaS with AI?

If you have a team management, role, or permissions page generated by an AI agent, and you want to know what happens when a user opens the console, check out the Audit offer. I list every DB write from the client, verify the RLS that protect them (or don't), and give you a prioritized fix plan.

And if you want this kind of writing on vibe coding (AI code audits, guides for shipping to prod, field reports), join the newsletter.

Sébastien Vanson

Sébastien Vanson

Software engineer with 11+ years of experience. I help founders building with AI go from prototype to production-ready product.

Newsletter

Stay in the loop

Practical tips on shipping AI-built products to production.
No spam, unsubscribe anytime.