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.
- 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.
- 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.
- Countermeasures built on the assumption that "the other side is manual and slow" should be reviewed.
- 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.
- "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 assumption | Why it weakens | What 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 responses | Use 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 match | Run authorization tests in CI every time |
| "Staff watch for suspicious access" | A large number of events appear in a short time | Automated 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 stopped | Keep 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.
| Character | Belongs to | Role |
|---|---|---|
| alice | Company A | admin (can create and update invoices) |
| amy | Company A | member (can read, cannot update) |
| bob | Company B | admin |
| stranger | Belongs to no company, but logged in | none |
| anon | Not logged in | none |
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:
- Roles are per tenant. An admin at Company A is not an admin at Company B. Look up
rolesfrom the server-side database on every request, keyed by the verifiedsub. A role embedded in a JWT is slow to be revoked. This function assumes the caller takes on that responsibility. - 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.
- The reason goes only in the audit log.
detaildistinguishes "cross-tenant" from "missing." - 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:
usingandwith check.usingis "which existing rows are visible or touchable," andwith checkis "whether the row after the write is allowed."insertrequireswith check. If you omitwith checkonupdate, PostgreSQL applies theusingexpression towith checkas well (per the official documentation). In fact, even withwith checkremoved, 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 onlyusing.- Do not take identity as an argument.
has_roletakes no user ID argument; it gets it fromauth.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.
| Situation | Operation | Expected | Measured |
|---|---|---|---|
| alice (Company A admin) | List invoices | Only Company A's 2 | 2 |
| alice | Read Company B's B1 by ID | 0 rows | 0 |
| alice | Read a nonexistent ID | 0 rows (indistinguishable from B1) | 0 |
| bob (Company B admin) | List | Only Company B's 1 | 1 |
| amy (Company A member) | List | Company A's 2 | 2 |
| amy | Update A1 | 0 rows (not an admin) | UPDATE 0 |
| alice | Update A1 | 1 row | UPDATE 1 |
| alice | Rewrite A1's tenant_id to Company B | Rejected | new row violates row-level security policy |
| alice | Same rewrite under a policy without with check | Rejected via reuse of using | Same as above |
| alice | Create an invoice for Company B | Rejected | Same as above |
| alice | Create an invoice for Company A | Allowed | INSERT 0 1 |
| amy | Create an invoice for Company A | Rejected (admin only) | Same RLS violation |
| alice | Delete A1 (no GRANT) | Rejected | permission denied for table invoices |
| alice | Delete A1 (GRANT, no policy) | RLS default deny | DELETE 0 |
| Logged in but belongs to no company | List | 0 rows | 0 |
| Logged in but claims empty | List | 0 rows (fails closed) | 0 |
Malformed sub (not-a-uuid) | List | Error, no data | invalid input syntax for type uuid |
| anon | List | Rejected (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.
| Function | alice names Company B's B1 | alice names her own A1 |
|---|---|---|
get_invoice_unsafe | B-Customer-1 was returned (a leak) | Returned |
get_invoice (re-checks membership) | 0 rows | 1 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
BYPASSRLSrole sees every tenant's rows. In the measurement too,app_servicereturned 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 deniedcomes 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.
| Situation | Expected | Measured |
|---|---|---|
| alice (authenticated): list own tenant | 2 rows | 2 |
| alice asks for tenant B's B1 | 0 rows | 0 |
| anon lists | RLS returns 0 rows rather than a denial | 0 (not permission denied) |
| anon inserts | rejected by RLS | new row violates row-level security policy |
alice calls get_invoice_unsafe for B1 | leak | B-Customer-1 returned |
| anon (not logged in) calls the same function for B1 | leak | B-Customer-1 returned |
Add FORCE RLS to the above | owner postgres has BYPASSRLS, so no effect | the leak is not stopped |
get_invoice with the membership re-check (B1 / own A1 / anon) | 0 / 1 / 0 rows | 0 / 1 / 0 |
Run only revoke execute ... from public | anon should be unable to call | anon could still call it (B-Customer-1) |
Run revoke execute ... from public, anon | anon denied | permission denied for function |
| service role lists | all rows | 3 |
| alice creates with another tenant's id | duplicate-key error (existence oracle) | duplicate key value violates unique constraint |
Four things follow.
anoncan 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.get_invoice_unsafereturned another company's invoice even to a logged-outanon. Execute is granted by default. This confirms, by measurement, what the Supabase documentation says.revoke ... from publicalone is not enough.anonandauthenticatedholdEXECUTEexplicitly, separately fromPUBLIC.anonwas refused only after revokingfrom public, anon. Thefrom publicin the main SQL is the plain-Postgres form.- On Supabase,
FORCE RLSdoes not stop the leak, because the definerpostgreshasBYPASSRLS.
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 JWT | her own 2 rows |
| alice's JWT, asking for B1's id | empty array (HTTP 200) |
GET /invoices with no JWT | empty array (HTTP 200); not a denial |
| a JWT with a wrong signature | HTTP 401 |
| service role JWT | all 3 rows |
POST /rpc/get_invoice_unsafe for B1 with no JWT | B-Customer-1 returned (HTTP 200) |
| the same RPC with alice's JWT | B-Customer-1 returned |
get_invoice with the membership re-check, for B1 | empty array |
POST /invoices with an id already in use | HTTP 409 (duplicate); with an unused id, HTTP 201 |
after revoke ... from public only, the same RPC with no JWT | still leaked |
after revoke ... from public, anon | permission 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
20and 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.