Skip to main content
Application-layer security
セキュリティ
認証・認可
Supabase
RLS
PostgreSQL
OWASP
脆弱性診断
Next.js

How Should Web Apps Prepare for ARTEX-Style AI Attacks? Testing Authorization Design for Next.js, APIs and Supabase by Actually Running It

AI attacks change speed and the barrier to entry, not what you defend. 29 measured checks of authorization and PostgreSQL RLS on a fictional SaaS, with full code.

Published
Reading time
27 min read
Author
友田 陽大
Share

Conclusion: speed and the barrier to entry change, the boundaries you defend do not

Public reports surfaced one after another in autumn 2026 saying that agentic AI attack frameworks such as ARTEX had been used in real attacks. How ARTEX itself works, and a summary of those reports, are in What is ARTEX? How AI-driven autonomous penetration testing works.

The substance of defense has not fundamentally changed. The Google Threat Intelligence Group (GTIG) describes the defenses it recommends as "control-plane separation, short-lived credentials, least privilege, segmentation and behavioral detection, all long-standing best practices." GTIG also says that, given attack at scale, defensive strategy needs to be rethought from the ground up.

Two things have changed.

  1. Speed and parallelism. GTIG says that investigation, code fixes, isolating failures and choosing the next move, which took an operator hours, can happen in minutes.
  2. The barrier to entry. GTIG cites the low barrier to adoption as part of what makes these frameworks attractive to a wide range of threat actors.

This article makes three claims.

  1. Countermeasures built on the assumption that "the other side is manual and slow" should be reviewed.
  2. The boundaries you defend (separating authentication from authorization, tenant isolation, least privilege) are the same. But the value of being able to show, with tests, that they actually work goes up.
  3. "We turned on RLS" or "we added a condition to the SQL" does not give complete safety. We ran the code and show where holes remain.

This article is not a reproduction of a real ARTEX attack. It tests the defenses of a typical web app on a fictional SaaS.

Why talk about authorization at all

To be honest: most of the reported cases involve Active Directory, cloud keys and permissions granted to connected apps, and are not tenant isolation in a web app as such. I still cover authorization for three reasons, which are my own judgment. (1) GTIG recorded abuse of a writable admin flag (is_admin=true) and attempts to read other users' attachments. (2) JPCERT/CC has reported attempts against internet-facing web apps in Japan. (3) Authorization is an area where a large part can be verified by yourself.

The conclusion for executives

  • What to check is not technical detail but whether you can show, with tests, that "other companies' data is not visible."
  • Do those tests run automatically on every change?
  • If a breach happens, how far does it spread, and how quickly would you notice?

I do not give loss amounts or probabilities, because any such number would have no basis. The kinds of impact that could occur are leakage of customer information, contractual problems with customers, the effort of investigation, notification and audit response, and loss of trust.

Terms

  • Tenant: the data area for one contracting company.
  • RLS (Row Level Security): a mechanism in which the database itself decides "this row is shown only to this person."

The vulnerabilities in this article — is your app safe from them?

An expert audit of your Next.js × Supabase authorization & RLS

What a WAF can and cannot stop

API1 (Broken Object Level Authorization, BOLA) in the OWASP API Security Top 10 2023 is the canonical authorization flaw. The login succeeds, the request is well formed, and changing only the ID to someone else's returns someone else's data. OWASP calls for checking permissions in every function that accesses a record from client input, using hard-to-guess IDs, and writing tests that assess authorization vulnerabilities and not deploying changes that fail them.

A WAF cannot stop this because the request is legitimate in every formal respect. As discussed in Defense in depth with a WAF, a WAF is an additional layer, not a substitute for authorization. How this arises in Next.js × Supabase is covered in Finding and fixing IDOR/BOLA.

Assumptions that get weaker [Design analysis]

Old assumptionWhy it weakensWhat to do instead
"IDs are hard to guess, so we're fine"Attempts are cheap and parallel, and failure carries no penalty. IDs leak through URLs, logs and responsesUse hard-to-guess IDs, but keep authorization separate
"We get assessed regularly"The pace of change in the exposed surface and the frequency of attack attempts do not matchRun authorization tests in CI every time
"Staff watch for suspicious access"A large number of events appear in a short timeAutomated detection with thresholds, and temporary blocking
"Lockout makes brute force harmless"In the spraying Microsoft observed, about 98% of responses were lockouts and none succeeded (what stopped it is the author's interpretation). Other paths were not stoppedKeep lockout, and take inventory of external apps' permissions and credentials

GTIG also assesses agentic attacks as noisy: forensically far noisier than a skilled human. For defenders, that means more chances to detect. But GTIG points out that detection and prevention are different, and detecting in time does not prevent the attacker from achieving the objective.

The subject: a fictional B2B SaaS

Company A and Company B use the same web app, and each stores invoices.

CharacterBelongs toRole
aliceCompany Aadmin (can create and update invoices)
amyCompany Amember (can read, cannot update)
bobCompany Badmin
strangerBelongs to no company, but logged innone
anonNot logged innone

There are two invoices for Company A (A1, A2) and one for Company B (B1). Threat model: the attacker has a valid account (alice at Company A) and aims to read, write and confirm the existence of Company B's invoice B1. The methods are swapping the ID in API calls, creating or updating with another company's tenant_id, and updating with a role that lacks permission. Out of scope: server takeover, key leakage, insider abuse by operators, and supply chain.

 Browser (untrusted)
    |  request + session token
    v
 [Gateway / Auth]   verifies signature, expiry, audience -> verified claims (sub)
    |
    v
 [App server]       L2: authorize(principal, action, resource)   <- TypeScript
    |   query
    v
 [PostgreSQL]       L3: Row Level Security (policies + WITH CHECK) <- SQL

 Paths AROUND L3: service role / BYPASSRLS / SECURITY DEFINER / owner
  • L1 gateway: verifies the token's signature, expiry and audience, and passes only the verified principal (sub) downstream. JWT pitfalls are in JWT attack techniques.
  • L2 app authorization: decides per resource.
  • L3 database RLS: the last line, so that even if the app gets it wrong, another tenant's rows are not returned.

The layers overlap because every layer is wrong on its own sometimes.

L2: app-layer authorization (TypeScript)

The decision is a pure function with only inputs and outputs. With no I/O, every rule can be unit tested.

// invoice-access.ts — Prerequisites: Node 24 (runs .ts directly by stripping types), TypeScript strict. No external dependencies.
export type Role = "admin" | "member";
export type Action = "read" | "update";
export type Principal = { readonly userId: string; readonly roles: ReadonlyMap<string, Role> }; // tenantId -> role
export type Invoice = { readonly id: string; readonly tenantId: string; readonly amountJpy: number };

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

/** Trust boundary: narrows unknown claims to a Principal. Returns null (= unauthenticated) if invalid. Never throws. */
export function toPrincipal(claims: unknown, roles: ReadonlyMap<string, Role>): Principal | null {
  if (typeof claims !== "object" || claims === null) return null;
  const sub = (claims as { sub?: unknown }).sub;
  return typeof sub === "string" && UUID.test(sub) ? { userId: sub, roles } : null;
}

/** detail is for the audit log only. Never put it in the HTTP response. */
export type AuditDetail = "ok" | "unauthenticated" | "missing" | "cross_tenant" | "role_insufficient";
export type Outcome = { readonly status: 200 | 401 | 403 | 404; readonly body: { readonly error?: string; readonly invoice?: Invoice } };
export type AuditEvent = { readonly principalId: string | null; readonly action: Action; readonly invoiceId: string; readonly decision: "allow" | "deny"; readonly detail: AuditDetail };

const NOT_FOUND: Outcome = { status: 404, body: { error: "not_found" } };

export function authorize(principal: Principal | null, action: Action, invoiceId: string, store: ReadonlyMap<string, Invoice>): { outcome: Outcome; audit: AuditEvent } {
  const ev = (decision: "allow" | "deny", detail: AuditDetail): AuditEvent => ({ principalId: principal?.userId ?? null, action, invoiceId, decision, detail });
  if (!principal) return { outcome: { status: 401, body: { error: "unauthenticated" } }, audit: ev("deny", "unauthenticated") };
  const invoice = store.get(invoiceId);
  if (!invoice) return { outcome: NOT_FOUND, audit: ev("deny", "missing") };
  const role = principal.roles.get(invoice.tenantId);
  // To the outside, another tenant's invoice looks exactly like "no such invoice" (same status, same body) ...
  if (!role) return { outcome: NOT_FOUND, audit: ev("deny", "cross_tenant") };           // ... but the audit log tells them apart.
  if (action === "update" && role !== "admin") return { outcome: { status: 403, body: { error: "forbidden" } }, audit: ev("deny", "role_insufficient") };
  return { outcome: { status: 200, body: { invoice } }, audit: ev("allow", "ok") };
}

The design points:

  1. Roles are per tenant. An admin at Company A is not an admin at Company B. Look up roles from the server-side database on every request, keyed by the verified sub. A role embedded in a JWT is slow to be revoked. This function assumes the caller takes on that responsibility.
  2. Another tenant's ID gets the same 404 as a nonexistent one. A 403 would tell the caller that the ID exists. When the caller is in the right tenant but lacks the permission, the resource is already visible to them, so it is a 403.
  3. The reason goes only in the audit log. detail distinguishes "cross-tenant" from "missing."
  4. Validating the ID format (is it a UUID?) is assumed to happen at the route boundary.

Trade-off: this function fetches by ID and then decides on permission. If you add the tenant condition at fetch time (where id = $1 and tenant_id = any($2)), another tenant's row never enters the app, but the audit log can no longer tell "cross-tenant" from "missing." Which to prioritize is a design decision. Either way, L3's RLS remains as the last line, which is the reason for layering.

Tests (8)

// invoice-access.test.ts — node --test invoice-access.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { authorize, toPrincipal, type Invoice, type Role } from "./invoice-access.ts";

const TA = "aaaaaaaa-0000-0000-0000-000000000001", TB = "bbbbbbbb-0000-0000-0000-000000000002";
const A1 = "11111111-0000-0000-0000-0000000000a1", B1 = "22222222-0000-0000-0000-0000000000b1", NONE = "99999999-0000-0000-0000-000000000000";
const store = new Map<string, Invoice>([
  [A1, { id: A1, tenantId: TA, amountJpy: 1000 }],
  [B1, { id: B1, tenantId: TB, amountJpy: 9000 }],
]);
const who = (sub: string, roles: [string, Role][]) => toPrincipal({ sub }, new Map(roles))!;
const alice = who("a1111111-0000-0000-0000-000000000001", [[TA, "admin"]]);
const amy = who("a2222222-0000-0000-0000-000000000002", [[TA, "member"]]);
const dual = who("d5555555-0000-0000-0000-000000000005", [[TA, "admin"], [TB, "member"]]);

test("unauthenticated -> 401, audit has no principal", () => {
  const r = authorize(null, "read", A1, store);
  assert.equal(r.outcome.status, 401); assert.equal(r.audit.principalId, null);
});
test("own-tenant read -> 200; admin update -> 200", () => {
  assert.equal(authorize(alice, "read", A1, store).outcome.status, 200);
  assert.equal(authorize(alice, "update", A1, store).outcome.status, 200);
});
test("cross-tenant id and nonexistent id are indistinguishable to the caller, distinguishable in the audit log", () => {
  const cross = authorize(alice, "read", B1, store), none = authorize(alice, "read", NONE, store);
  assert.deepEqual(cross.outcome, none.outcome);
  assert.equal(cross.audit.detail, "cross_tenant"); assert.equal(none.audit.detail, "missing");
});
test("cross-tenant WRITE is also 404 (never 403)", () => {
  assert.equal(authorize(alice, "update", B1, store).outcome.status, 404);
  assert.equal(authorize(amy, "update", B1, store).outcome.status, 404);
});
test("member can read but not update own tenant -> 403", () => {
  assert.equal(authorize(amy, "read", A1, store).outcome.status, 200);
  const r = authorize(amy, "update", A1, store);
  assert.equal(r.outcome.status, 403); assert.equal(r.audit.detail, "role_insufficient");
});
test("roles are per tenant: admin in A is only a member in B", () => {
  assert.equal(authorize(dual, "read", B1, store).outcome.status, 200);
  assert.equal(authorize(dual, "update", B1, store).outcome.status, 403);
  assert.equal(authorize(dual, "update", A1, store).outcome.status, 200);
});
test("denied responses never leak the invoice body", () => {
  for (const r of [authorize(alice, "read", B1, store), authorize(amy, "update", A1, store), authorize(null, "read", A1, store)])
    assert.equal(r.outcome.body.invoice, undefined);
});
test("toPrincipal fails closed on malformed claims", () => {
  for (const bad of [undefined, null, 42, "x", {}, { sub: 7 }, { sub: "not-a-uuid" }, { sub: "' or 1=1 --" }])
    assert.equal(toPrincipal(bad, new Map()), null);
});

All 8 passed, and the tsc --strict --noEmit --allowImportingTsExtensions type check passed too (the flag is needed because the test imports with a .ts extension). To confirm that the tests can actually fail, I introduced a mutation that returns 403 on cross-tenant access, and 2 tests failed. A test that cannot fail guarantees nothing.

L3: PostgreSQL RLS (full listing)

Prerequisites: PostgreSQL 14.23 (the local Homebrew build, a throwaway cluster not exposed externally). The main lab is plain PostgreSQL that mimics auth.uid() with a stub built from standard features. Role names and default privileges differ from the real thing, so I re-checked the key pitfalls afterwards on the official Supabase Postgres image (17.6); see "Re-checking on the official Supabase image" below.

-- Fictional B2B SaaS: two tenants, invoices. LOCAL LAB ONLY.
create role app_owner nologin;                 -- owns tables (migration role)
create role app_user  nologin;                 -- ~ Supabase "authenticated"
create role app_anon  nologin;                 -- ~ Supabase "anon"
create role app_service nologin bypassrls;     -- ~ Supabase "service_role"
create role app_definer nologin bypassrls;     -- a definer role that bypasses RLS

create schema auth;   create schema private;
create function auth.uid() returns uuid language sql stable
  as $$ select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid $$;  -- assumes verified claims; a client with a direct SQL connection can forge it via set_config, so lab only
grant usage on schema auth to app_owner, app_definer, app_user, app_anon, app_service;
grant execute on function auth.uid() to app_owner, app_definer, app_user, app_anon, app_service;

alter schema private owner to app_owner;
grant usage on schema public to app_owner, app_definer, app_user, app_anon, app_service;
grant create on schema public to app_owner;

set role app_owner;
create table public.tenants     (id uuid primary key, name text not null);
create table public.memberships (tenant_id uuid references public.tenants, user_id uuid not null,
                                 role text not null check (role in ('admin','member')),
                                 primary key (tenant_id, user_id));
create table public.invoices    (id uuid primary key, tenant_id uuid not null references public.tenants,
                                 customer text not null, amount_jpy integer not null check (amount_jpy >= 0),
                                 status text not null default 'draft' check (status in ('draft','sent','void')));

-- SECURITY DEFINER helper: runs with the owner's privileges. Keep it small, pin search_path, and do not take a user ID as an argument.
create function private.has_role(p_tenant uuid, p_roles text[]) returns boolean
  language sql stable security definer set search_path = ''
  as $$ select exists (select 1 from public.memberships m
                        where m.tenant_id = p_tenant and m.user_id = auth.uid() and m.role = any(p_roles)) $$;
revoke all on function private.has_role(uuid, text[]) from public;
grant usage on schema private to app_user, app_definer;
grant execute on function private.has_role(uuid, text[]) to app_user, app_definer;

alter table public.tenants     enable row level security;  alter table public.tenants  force row level security;
alter table public.memberships enable row level security;   -- no FORCE (the reason is in pitfall 2)
alter table public.invoices    enable row level security;  alter table public.invoices force row level security;

create policy memberships_self on public.memberships for select to app_user using (user_id = auth.uid());
create policy tenants_member   on public.tenants     for select to app_user using (private.has_role(id, array['admin','member']));
create policy inv_select on public.invoices for select to app_user
  using (private.has_role(tenant_id, array['admin','member']));
create policy inv_insert on public.invoices for insert to app_user
  with check (private.has_role(tenant_id, array['admin']));
create policy inv_update on public.invoices for update to app_user
  using (private.has_role(tenant_id, array['admin']))
  with check (private.has_role(tenant_id, array['admin']));   -- state explicitly what the updated row must satisfy
-- no DELETE policy

grant select on public.tenants, public.memberships to app_user;
grant select, insert, update on public.invoices to app_user;     -- grant nothing to app_anon
grant select, insert, update, delete on all tables in schema public to app_service;

-- THE PITFALL: a SECURITY DEFINER function that does not check the caller's tenant
create function public.get_invoice_unsafe(p_id uuid) returns setof public.invoices
  language sql stable security definer set search_path = ''
  as $$ select * from public.invoices where id = p_id $$;
-- FIX: the same function, plus a re-check of the caller's membership
create function public.get_invoice(p_id uuid) returns setof public.invoices
  language sql stable security definer set search_path = ''
  as $$ select i.* from public.invoices i where i.id = p_id and private.has_role(i.tenant_id, array['admin','member']) $$;
reset role;

-- (superuser) hand the two functions to the definer that bypasses RLS, and narrow EXECUTE
alter function public.get_invoice_unsafe(uuid) owner to app_definer;
alter function public.get_invoice(uuid) owner to app_definer;
grant select on public.invoices to app_definer;
revoke all on function public.get_invoice_unsafe(uuid), public.get_invoice(uuid) from public;   -- enough on plain Postgres; on Supabase anon is granted explicitly, so you need from public, anon
grant execute on function public.get_invoice_unsafe(uuid), public.get_invoice(uuid) to app_user;

-- Seed data (loaded as the superuser, which bypasses RLS)
insert into public.tenants values ('aaaaaaaa-0000-0000-0000-000000000001','Tenant A'),('bbbbbbbb-0000-0000-0000-000000000002','Tenant B');
insert into public.memberships values
 ('aaaaaaaa-0000-0000-0000-000000000001','a1111111-0000-0000-0000-000000000001','admin'),   -- alice
 ('aaaaaaaa-0000-0000-0000-000000000001','a2222222-0000-0000-0000-000000000002','member'),  -- amy
 ('bbbbbbbb-0000-0000-0000-000000000002','b3333333-0000-0000-0000-000000000003','admin');   -- bob
insert into public.invoices(id,tenant_id,customer,amount_jpy) values
 ('11111111-0000-0000-0000-0000000000a1','aaaaaaaa-0000-0000-0000-000000000001','A-Customer-1',1000),
 ('11111111-0000-0000-0000-0000000000a2','aaaaaaaa-0000-0000-0000-000000000001','A-Customer-2',2000),
 ('22222222-0000-0000-0000-0000000000b1','bbbbbbbb-0000-0000-0000-000000000002','B-Customer-1',9000);

Points to note:

  • using and with check. using is "which existing rows are visible or touchable," and with check is "whether the row after the write is allowed." insert requires with check. If you omit with check on update, PostgreSQL applies the using expression to with check as well (per the official documentation). In fact, even with with check removed, rewriting a row into another tenant was rejected (measured). The value of stating it explicitly is that it shows what you require of the updated row, and it keeps the post-update check from loosening if someone later relaxes only using.
  • Do not take identity as an argument. has_role takes no user ID argument; it gets it from auth.uid().
  • With no policy, access is denied by default. This is stated in the official PostgreSQL documentation (default-deny).

How it was verified

Each check was run independently in a transaction that is rolled back. The core is below ($PSQL is psql -tA).

# 1 check = 1 transaction. It ROLLBACKs, so results do not depend on the order of checks.
printf "begin;\n%s\nset local role %s;\nselect set_config('request.jwt.claim.sub','%s',true) \\\\gset\n%s\nrollback;\n" \
  "$pre" "$role" "$sub" "$sql" | $PSQL 2>&1 | grep -v -E '^(BEGIN|SET|ROLLBACK|GRANT|REVOKE|ALTER [A-Z ]+)$'
# First take a positive control, "alice sees her own company's 2 rows", and abort everything if it fails
# (if the setup is broken, every "denied" result becomes a false success)

In the first run, a setup bug made only the anon checks falsely succeed. The positive control was added because of that experience.

Results (18 representative checks out of 29)

These were measured with the main lab's GRANTs (before the Pitfall 3 fix, with update granted on every column). After the fix, rewriting tenant_id stops at permission denied, before RLS. The with check rows still matter: if someone later grants update(tenant_id) again, RLS remains as a second wall.

SituationOperationExpectedMeasured
alice (Company A admin)List invoicesOnly Company A's 22
aliceRead Company B's B1 by ID0 rows0
aliceRead a nonexistent ID0 rows (indistinguishable from B1)0
bob (Company B admin)ListOnly Company B's 11
amy (Company A member)ListCompany A's 22
amyUpdate A10 rows (not an admin)UPDATE 0
aliceUpdate A11 rowUPDATE 1
aliceRewrite A1's tenant_id to Company BRejectednew row violates row-level security policy
aliceSame rewrite under a policy without with checkRejected via reuse of usingSame as above
aliceCreate an invoice for Company BRejectedSame as above
aliceCreate an invoice for Company AAllowedINSERT 0 1
amyCreate an invoice for Company ARejected (admin only)Same RLS violation
aliceDelete A1 (no GRANT)Rejectedpermission denied for table invoices
aliceDelete A1 (GRANT, no policy)RLS default denyDELETE 0
Logged in but belongs to no companyList0 rows0
Logged in but claims emptyList0 rows (fails closed)0
Malformed sub (not-a-uuid)ListError, no datainvalid input syntax for type uuid
anonListRejected (in this lab anon has no grants; Supabase defaults differ, see below)permission denied for table invoices

The remaining 11 checks are covered in the next section. Note that only what was written was shown to work.

Pitfalls found in testing

Pitfall 1: SECURITY DEFINER functions walk straight through RLS

A SECURITY DEFINER function runs with the privileges of the function's owner, not the caller. The official PostgreSQL documentation says superusers and roles with BYPASSRLS always bypass RLS. The Supabase documentation states that security definer functions can return rows the caller is not allowed to read, that functions created in the dashboard or in migrations are owned by postgres and so bypass RLS, and that by default any role, including anon, can call them.

This test reproduces that by measurement. I set the owner to BYPASSRLS-holding app_definer and compared two functions that do the same thing.

Functionalice names Company B's B1alice names her own A1
get_invoice_unsafeB-Customer-1 was returned (a leak)Returned
get_invoice (re-checks membership)0 rows1 row

There are two countermeasures. (1) Re-check the caller's membership inside the function. (2) Narrow execute privileges. Pinning search_path protects against hijacking; it does not protect against crossing tenants. On Supabase, take care with (2). What I found on the real image is in "Re-checking on the official Supabase image" below.

Pitfall 2: FORCE RLS causes "breakage," not defense (on plain Postgres)

If the owner does not have BYPASSRLS and the table has FORCE ROW LEVEL SECURITY, RLS also applies to the definer's function. When I changed the owner of the same get_invoice_unsafe to such a role, B1 was not returned and the result was 0 rows. But the caller's own A1 also returned 0 rows. The policies target only app_user, so no policy applied to the owner and default-deny took over. This is not the function filtering by the caller's tenant; the function broke for everyone. Do not rely on it as a defense.

For the same reason, putting FORCE RLS on the helper table memberships made the helper function always return false and every invoice came back as 0 rows (measured). For troubleshooting, see RLS troubleshooting.

But this is behavior of the plain-Postgres lab. On Supabase's defaults, the postgres role that becomes the definer has BYPASSRLS, so FORCE RLS has no effect and the leak is not stopped (measured in the re-check below). So on Supabase you can expect neither defense nor breakage from FORCE RLS.

Pitfall 3: a client-supplied primary key leaks existence

Even if L2 returns "the same 404 for another tenant's ID as for a nonexistent one," the create API can open a hole. If the client can set invoices.id, when alice tried to create her own invoice using Company B's B1 ID, PostgreSQL returned the following (measured).

ERROR: duplicate key value violates unique constraint "invoices_pkey"

A random unused ID succeeds. So even with RLS, the duplicate error reveals that the ID exists. An update opens the same hole. With update granted on every column, when alice rewrote the id of her own A1 to B1's ID, the same duplicate error came back, and an unused ID went through as UPDATE 1 (measured). The fix is to generate IDs on the server and let the client write them neither on create nor on update.

alter table public.invoices alter column id set default gen_random_uuid();
revoke insert on public.invoices from app_user;
grant insert (tenant_id, customer, amount_jpy) on public.invoices to app_user;   -- do not include id
revoke update on public.invoices from app_user;
grant update (customer, amount_jpy, status) on public.invoices to app_user;      -- do not include id or tenant_id

After the fix, both a create that specified an ID and an update that rewrote id got permission denied; a normal create that omitted the ID went through as INSERT 0 1, and an update of status as UPDATE 1 . Because tenant_id is also left out of the updatable columns, moving a row to another tenant stopped at permission denied, before RLS is even consulted (all measured; the three update checks are outside the 29 and were added after a review pointed out the gap).

On Supabase, renaming the role is not enough. By default every table privilege is granted to both anon and authenticated, so revoke from both, then grant the columns to authenticated only. The id default (gen_random_uuid()) is still required, as above (the re-check on the Supabase image below did not test this column-level fix itself).

alter table public.invoices alter column id set default gen_random_uuid();
revoke insert, update on public.invoices from anon, authenticated;
grant insert (tenant_id, customer, amount_jpy) on public.invoices to authenticated;
grant update (customer, amount_jpy, status) on public.invoices to authenticated;

The service role and privileges (GRANT)

  • A BYPASSRLS role sees every tenant's rows. In the measurement too, app_service returned all 3. The Supabase documentation says the service role bypasses RLS and should be kept on the server. Processing that runs under this role needs the application code to scope by tenant itself (Exposure of the anon key and service role key).
  • I confirmed the two stages: with no GRANT, permission denied comes before RLS; with a GRANT but no policy, RLS's default deny applies. The Supabase documentation also cautions that adding a policy does not revoke a GRANT. Design privileges and row visibility separately.

Re-checking on the official Supabase image

The main lab is plain PostgreSQL, so I checked whether the claims hold on the real thing in two stages. (1) Run the SQL directly in a throwaway container of Supabase's official Postgres image (17.6). (2) Put PostgREST (v14.5) in front of that image and re-check with real HTTP requests. Nothing was exposed externally (HTTP bound to 127.0.0.1 only) and everything was deleted afterwards. I left the default privileges untouched and used the real roles anon, authenticated, service_role and postgres. The JWTs in stage (2) were signed by me with HS256; GoTrue was not started.

Role attributes (real): postgres and service_role have BYPASSRLS; anon and authenticated do not. Default privileges (real): tables created by postgres give anon, authenticated and service_role all privileges, and functions get EXECUTE.

SituationExpectedMeasured
alice (authenticated): list own tenant2 rows2
alice asks for tenant B's B10 rows0
anon listsRLS returns 0 rows rather than a denial0 (not permission denied)
anon insertsrejected by RLSnew row violates row-level security policy
alice calls get_invoice_unsafe for B1leakB-Customer-1 returned
anon (not logged in) calls the same function for B1leakB-Customer-1 returned
Add FORCE RLS to the aboveowner postgres has BYPASSRLS, so no effectthe leak is not stopped
get_invoice with the membership re-check (B1 / own A1 / anon)0 / 1 / 0 rows0 / 1 / 0
Run only revoke execute ... from publicanon should be unable to callanon could still call it (B-Customer-1)
Run revoke execute ... from public, anonanon deniedpermission denied for function
service role listsall rows3
alice creates with another tenant's idduplicate-key error (existence oracle)duplicate key value violates unique constraint

Four things follow.

  1. anon can read the table, but RLS returns 0 rows. The "no grant, so denied" behavior of the main lab differs from Supabase defaults. RLS is the only wall.
  2. get_invoice_unsafe returned another company's invoice even to a logged-out anon. Execute is granted by default. This confirms, by measurement, what the Supabase documentation says.
  3. revoke ... from public alone is not enough. anon and authenticated hold EXECUTE explicitly, separately from PUBLIC. anon was refused only after revoking from public, anon. The from public in the main SQL is the plain-Postgres form.
  4. On Supabase, FORCE RLS does not stop the leak, because the definer postgres has BYPASSRLS.

The real auth.uid() and the results over HTTP

The stub in the main lab reads only request.jwt.claim.sub. The real auth.uid(), defined by the Supabase auth server (supabase/auth) migration 20220224000811, reads request.jwt.claim.sub and, if that is empty, falls back to the sub of request.jwt.claims (JSON). PostgREST passes claims in the latter form, so on the real HTTP path the fallback branch is the one used. The stub imitates only the first half. After swapping in the real definition and going through PostgREST:

HTTP request (measured)Result
GET /invoices with alice's JWTher own 2 rows
alice's JWT, asking for B1's idempty array (HTTP 200)
GET /invoices with no JWTempty array (HTTP 200); not a denial
a JWT with a wrong signatureHTTP 401
service role JWTall 3 rows
POST /rpc/get_invoice_unsafe for B1 with no JWTB-Customer-1 returned (HTTP 200)
the same RPC with alice's JWTB-Customer-1 returned
get_invoice with the membership re-check, for B1empty array
POST /invoices with an id already in useHTTP 409 (duplicate); with an unused id, HTTP 201
after revoke ... from public only, the same RPC with no JWTstill leaked
after revoke ... from public, anonpermission denied for function (HTTP 401)

An HTTP request with no login and not even a JWT returned another company's invoice. That is the heaviest fact in this check. The EXECUTE privilege design of any function you expose as an RPC is, directly, the design of an entrance facing the internet.

This check also shows only what was written.

Logging and detection: finding enumeration as a count

At L2 we return the same 404 to the outside and distinguish cross_tenant from missing internally. Using that, we look at the count of "one principal denied on many different IDs in a short time."

select principal_id,
       count(distinct invoice_id) as distinct_denied_ids,
       min(at) as first_seen, max(at) as last_seen
from audit_events
where at >= now() - interval '5 minutes'
  and detail in ('cross_tenant', 'missing')
group by principal_id
having count(distinct invoice_id) >= 20;     -- 20 is illustrative. Set it from your own baseline

Run on synthetic data (principal P1 was denied on 60 different IDs in about 30 seconds; principal P2, in normal use, tried a cross-tenant ID once), the result was just P1's single row.

The limits, stated plainly.

  • This is close to a tautological check on synthetic data I built myself. Also, enumeration by trying 60 random UUIDs is not realistic. Real threats use leaked IDs or sequential IDs.
  • The threshold 20 and the 5-minute window are illustrative. Legitimate use also touches many IDs, through screen loads and bulk jobs. Decide after measuring your own normal distribution (OWASP API4 also asks that rate limits be tuned to the business).
  • If the attacker spreads across accounts or source addresses, per-principal aggregation will not see it.
  • Detection and prevention are different. Unless you also design what gets stopped automatically once something is detected, the outcome does not change.
  • Do not put invoice contents or personal information in the logs.

For the overall design of logging, see Security logging and detection engineering; for response after the fact, see Incident response based on NIST SP 800-61 Rev.3.

What this verification does not guarantee

  • The main lab is not Supabase itself. I re-checked the key pitfalls on the official Supabase Postgres image and over HTTP through PostgREST, but not GoTrue (real login and JWT issuance), a real Supabase project (e.g. asymmetric-key signing), Edge Functions or Realtime. Run the same kind of tests in your own environment.
  • The scope is reading and writing invoices in a fictional SaaS. Business logic, admin paths, batch jobs and external integrations are not included.
  • Side channels such as timing differences are untested. Even with a unified 404, existence might be inferred from processing time.
  • You cannot say "it's safe because we enabled RLS" or "it's safe because we added a condition to the SQL." Pitfalls 1 to 3 are examples of that.
  • The 29 checks and 8 tests show only what was written.

What you can check yourself, and where an independent review helps

I offer paid web application security audits. Because this section has a conflict of interest, I put the cases where doing it yourself is enough first.

What you can check yourself with little effort (free)

  • Write tests like the ones above for your own app and run them in CI. Confirm that other tenants' IDs are rejected and that roles without permission cannot update, and confirm with mutation tests that the tests fail when they should.
  • For Next.js × Supabase, the free OSS tool (Aegis) can show your current state: npx @aegiskit/cli scan, details at /aegis. A 20-question self-check for non-engineers is at /vibe-coding/checkup (answers never leave your browser).
  • For how to write RLS tests, see RLS regression tests with pgTAP.

Cases where an independent review can help

  • When you want a third party to look at whether the role design matches how the business actually works.
  • When you want to confirm that paths outside RLS (admins, operations tools, batch jobs, external integrations) do not break the isolation.
  • When reviewing code you wrote yourself has its limits, and you need to show customers or partners an independent check.

Cases where it is not necessarily needed: if you have few users, handle no sensitive data, have completed a round of the self-checks above, and no one is asking for independent assurance, it is more reasonable to run your own checks first.

The scope and pricing of an audit that reads the code to verify authorization, tenant isolation and configuration are at /security-audit, and the audit specialized for Next.js × Supabase is at /aegis/audit. I will not say "completely safe." I report the problems found and the remaining risk separately.

Frequently asked questions

If I have a WAF, does that stop automated attacks by AI?
Partly, but not authorization flaws. A WAF is good at spotting known attack patterns and excessive request rates. A request that logs in correctly, is well formed and names another tenant's ID looks normal in form. Who may touch which data is decided by the authorization in the app and the database. A WAF is an additional layer, not a substitute.
If I enable Supabase RLS, is tenant isolation safe?
Enabling it is not enough. In this article's tests, even with RLS on, another tenant's invoice came back when a SECURITY DEFINER function running with a role that bypasses RLS did not check the caller's tenant. The service role also passes straight through RLS. You need to design policies together with functions, roles, GRANTs, admin paths and regression tests.
If I use UUIDs for IDs, can't other people's data be guessed?
UUIDs make guessing harder, but they do not replace authorization. OWASP recommends hard-to-guess IDs while also requiring every function that accesses a record from client input to check that the logged-in user is entitled to it. UUIDs can leak through URLs, logs and API responses.
Shouldn't I return different errors for a nonexistent ID and another tenant's ID?
The response visible from outside should be the same for both (for example 404). A 403 tells the caller that the ID exists but is not theirs. Record the two cases separately in the internal audit log, though. Note also that a create or update API that lets the client write the primary key leaks existence through the duplicate-key error. The tests in this article confirmed that.
Where is the line between what I can check myself and what needs an external review?
What you can check yourself with little effort is reproducing, in tests, that other tenants' IDs are rejected. An independent external review helps when you need to know whether the role design matches how the business actually works, whether admin and operations-tool paths break the isolation, or when there is a limit to verifying your own work. It is not necessarily required.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

The vulnerabilities in this article — is your app safe from them?

An expert audit of your Next.js × Supabase authorization & RLS

The IDOR, RLS misconfigurations, and tenant-boundary crossing covered here are vertical risks a library can't fix. I take it on as a security audit — from authorization review through fix design and implementation. You're welcome to visualize the current state with the free OSS first.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading