Lantunnel
Install Lantunnel Client on each participating device to access your whole LAN from anywhere with P2P-first low-latency routing, encrypted transport, and relay fallback without port forwarding.
One read-only query in your Supabase SQL editor. Every open policy, table and function, quoted from your database, with the fix and the undo. Free.
SecurityTypeScript · PLpgSQLGitHub @audit0OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Open-source deterministic security scanner for Next.js + Supabase apps: cross-tenant reads, RLS gaps, SECURITY DEFINER functions, with schema-level fix migrations. The engine behind auditai.sh.
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
Your AI writes code. This scanner finds the bug it ships most: one customer reading another customer's data.
Deterministic security scanner for Next.js (App Router) + Supabase apps. No account, no model, no network. Seconds.Read the full README ↗npx auditai-scan --snapshot-query # a read-only query: run it in the Supabase SQL editor
npx auditai-scan --snapshot snapshot.json # what your live database lets a stranger do
npx auditai-scan . # your repository
$ npx auditai-scan --snapshot examples/snapshot.json
Audit AI database scan taken 2026-09-21T08:00:00Z
Postgres 17.6 · Tables 2 · With RLS 1/2 · Rules 15
AUDIT-001 LIKELY HIGH Table "invoices" is exposed without RLS
Entry GET /rest/v1/invoices (Supabase Data API), POST /rest/v1/invoices (Supabase Data API), PATCH /rest/v1/invoices (Supabase Data API), DELETE /rest/v1/invoices (Supabase Data API)
Path HTTP request -> GET /rest/v1/invoices (Supabase Data API) -> supabase (publishable key) (anon, RLS would apply) -> public.invoices (RLS disabled)
Why Row level security is off on public.invoices, and it is queried with the anon client. Anyone holding the public anon key can read every row directly through PostgREST. Reached from 4 entry points.
Rule supabase.table-without-rls · CWE-284, CWE-862 · confidence 0.90
Fix Enable row level security on public.invoices (supabase/migrations/20260921080000_fix_enable_rls_invoices.sql)
-- Turn on row level security for public.invoices
-- Proposed by Audit AI. Read it, then apply it with the rest of your migrations.
alter table public.invoices enable row level security;
create policy "invoices: owner reads" on public.invoices
for select to authenticated
using (user_id = (select auth.uid()));
create policy "invoices: owner writes" on public.invoices
for all to authenticated
using (user_id = (select auth.uid()))
with check (user_id = (select auth.uid()));
AUDIT-002 LIKELY HIGH Policy "profiles are editable" lets anyone update "profiles"
Entry Supabase Data API (PostgREST)
Path Anyone with the public anon key -> PostgREST -> policy "profiles are editable" (for update, to public) -> public.profiles
Why Policy "profiles are editable" on public.profiles is for update to public and decides with a tautology: USING true. Anyone holding the public anon key can update rows in public.profiles straight through PostgREST, without going through this application. Rows that belong to signed-in users can be changed or deleted by a stranger.
Rule supabase.anon-write-policy · CWE-284, CWE-862 · confidence 0.85
Fix Tie the write policy on public.profiles to the caller (supabase/migrations/20260921080000_fix_policy_profiles_update.sql)
-- Close the open write policy "profiles are editable" on public.profiles
-- Proposed by Audit AI. Read it, then apply it with the rest of your migrations.
drop policy "profiles are editable" on public.profiles;
create policy "profiles are editable" on public.profiles
for update to authenticated
using (id = (select auth.uid()))
with check (id = (select auth.uid()));
Checked 2 risks in class authorization/RLS. Verified: 0. Confirmed (no sandbox): 0. Unverified: 0.
Note: Application code was not read: the entry points here are the Data API endpoints your database serves, so nothing is said about service-role queries, missing authentication or mass assignment in your own routes.
Note: A clean result means the database refuses the accesses these rules test, not that the application is safe.
Note: Views, materialized views and foreign tables are not judged: row level security does not apply to them, and whether a view runs with its owner's rights is not checked yet.
This is the open-source engine behind Audit AI. The scanner gives you the deterministic part: every route, server action, page, Supabase client, query, RLS policy and database function, mapped and checked. The hosted product takes a finding from here and tries to prove it.
What a stranger can do with your data is decided by the database as it runs, not by the migrations
folder: a policy added in the dashboard, a table made by hand, a revoke that ran inside a DO block.
--snapshot-query prints one read-only SELECT over the Postgres catalog: tables and whether row
level security is on, policies, grants to anon and authenticated, functions, the triggers that
decide what a row may hold, enum values and storage buckets.
npx auditai-scan --snapshot-query, paste it into Supabase → SQL Editor → Run.snapshot.json (the editor's JSON or CSV export works too).npx auditai-scan --snapshot snapshot.json.The query reads the catalog and changes nothing. No row of your data is read: whether a table has
rows comes from the database's own statistics. The source of a function is included only when it is
SECURITY DEFINER or the function of a trigger that runs on sign-up or before a row is inserted or
updated, because only those decide who can do what. A snapshot shows the database's real triggers,
so the role, sign-up and credits rules (S6–S8) judge the live guards, not the migrations' guess.
Every finding comes with the
migration that closes it. The same check runs in the browser at
auditai.sh/check, where nothing you paste is stored.
A clean result means the database refuses what these rules test, not that the application is safe:
a snapshot says nothing about your own routes, which is what npx auditai-scan . reads.
54 seconds on the playground app: a scan by link, the finding in plain words, Prove it in the sandbox, Alice reading Bob's invoice, then the fix verified by the same attack. The opening message is illustrative and the voice is synthetic; everything on screen after it is the live product. Also on YouTube.
https://github.com/user-attachments/assets/50ac7fdf-3a84-4089-a106-44ada2a8f081
Reproduced only if the attack actually works. When the fix is a migration, the
sandbox applies it and runs the same attack again; Verified fix needs every DENY test to pass
and every ALLOW test to stay green. A person approves each sandbox run on a repository we have
not seen before.SECURITY DEFINER function), and all three are proven on the live product.Lovable, Bolt, v0, Cursor and Claude Code generate the same shape of app: Next.js App Router,
Supabase for auth and data, TypeScript. They also make the same class of mistake: a route handler
that uses the service-role key and filters by id only, an RLS policy that says using (true), a
tenant id read from the request body, a role read from user_metadata. Generic scanners do not see
these because they do not understand the framework. This one does nothing else.
Rule packs supabase-authorization, supabase-storage-rpc and supabase-sql-policies. Every rule
ships with a vulnerable fixture that must fire and a secure fixture that must stay silent.
| Rule | Kind | Severity | What it catches |
|---|---|---|---|
supabase.table-without-rls | headline | high | Table queried by a user-facing client, or served by the Data API, has row level security disabled |
supabase.anon-write-policy | headline | high, medium when insert-only | Insert/update/delete policy open to anon (or no TO clause) whose predicate is true |
supabase.security-definer-function-without-caller-check | headline | high, critical if anon can execute | SECURITY DEFINER function (RLS skipped inside) that never reads auth.uid(), callable through supabase.rpc() |
supabase.rls-policy-trusts-user-metadata | headline | critical | RLS policy decides access from a user_metadata claim, which the user writes themselves with updateUser({ data }) |
supabase.policies-without-rls-enabled | headline | high | A table carries policies and never got enable row level security, so none of them apply |
supabase.role-from-signup-metadata | lead | critical | A sign-up trigger on auth.users copies a role from raw_user_meta_data (set by the client in signUp({ options: { data } })) into a column that decides admin, or the tenant id the policies scope by |
supabase.self-assignable-role-column | lead | critical | A column of the user's own row decides admin (a route's role check, an is_admin() helper or a policy) and an own-row UPDATE policy leaves it writable: no WITH CHECK, column privilege or BEFORE UPDATE trigger holds it |
supabase.self-writable-entitlement-column | lead | high | A column of the user's own row holds credits, a balance, a plan or a KYC status that the server decides on (a route exits when profile.credits <= 0, a SQL function or policy compares it) and an own-row UPDATE policy leaves it writable: no WITH CHECK, column privilege or trigger. One PATCH gives a user credits or a paid plan. Points, coins and gems at medium |
Severity is the rule's own rating. A lead is printed at medium at most, with this rating next to it.
Findings are reported as likely, never confirmed: confirmation needs evidence, and evidence
means a reproduced request. Suppressed findings stay in the output, marked suppressed.
The database rules (anon-write-policy, rls-policy-trusts-user-metadata,
policies-without-rls-enabled, view-runs-with-owner-rights, and security-definer-function-without-caller-check and
dynamic-sql-from-function-parameter for functions the app never calls) need no query from your application, because PostgREST exposes the schema to
anyone holding the public key. Their findings name the Data API as the entry point instead of a
route.
Findings whose fix follows from the schema alone come with that fix: one migration, printed under
the finding and included in --json as fix. No model is involved and nothing is applied: it is
SQL to read and apply yourself.
AUDIT-001 LIKELY HIGH SECURITY DEFINER function "get_invoice" without a caller check
Entry GET /api/invoices/[id], POST /rest/v1/rpc/get_invoice supabase/migrations/0001_init.sql:55
...
Fix Run get_invoice with the caller's rights (security invoker) (supabase/migrations/20260913175911_fix_security_invoker_get_invoice.sql)
-- Run get_invoice with the caller's rights, so row level security applies inside it
-- Proposed by Audit AI. Read it, then apply it with the rest of your migrations.
-- It reads public.invoices, and each of them has row level security with policies.
alter function public.get_invoice(uuid) security invoker;
Today that covers the SECURITY DEFINER, row-level-security and write-policy rules, about a
quarter of what the scanner reports on real repositories. Every table, function and policy name in
a proposed migration is resolved against the parsed schema. A finding whose fix lives in
application code gets no proposal, on purpose.
Headlines and leads. Since 21 September 2026 the output keeps two kinds of claim apart, by what
each rule reads. Rules that read a fact about the database (row level security off, a write policy
open to anyone, a policy that trusts user_metadata, a SECURITY DEFINER function callable without
a check on the caller) make headlines: on the four blind samples below they were right 78 times
out of 141 (55%). Rules that infer from application code make leads: right 62 times out of 249
(25%). A lead is printed under its own heading, capped at medium with the rule's own rating next to
it, and never fails --fail-on.
Measured on public repositories the engine had never seen. The selection rule and the sample size
were written down and committed before any repository was picked; the sample was drawn before any
code was read. Every sampled finding is then read against the code and labeled with a one-sentence
reason that cites the line. Precision is real ÷ (real + false positive); findings labeled unsure
are left out of the denominator.
| Blind sample | Repositories | Findings labeled | Precision | Blocking tier (high + critical) |
|---|---|---|---|---|
| 1st, 13 Sep 2026 | 20 | 100 | 36% (36/100) | 35% (32/92) |
| 2nd, 13 Sep 2026, after precision rounds 4–5 | 20 new | 100 | 46% (44/95, 95% interval 37–56%) | 49% (43/87) |
| 3rd, 14 Sep 2026, after precision round 6 | 20 new | 100 | 31% (31/99, 95% interval 23–41%) | 32% (29/92) |
| 4th, 19 Sep 2026, after precision round 7 | 30 new | 100 | 30% (29/96, 95% interval 22–40%) | 32% (27/85) |
| 5th, 23 Sep 2026, after headlines/leads and precision round 8 | 30 new | 100 | 22% (22/100, 95% interval 15–31%) | 18% (3/17) |
EXECUTE on every
SECURITY DEFINER function with a loop over pg_proc, which the engine does not read. Because
leads never block, only 17 of the 100 findings are in the blocking tier, so that column says
little this time. A second labeller agreed on 27 of 30 (90%, Cohen's kappa 0.67).middleware.ts with its own signed cookie, a role inside a signed session, an admin helper with an
early return (18 of 67 false positives); ownership established in code, such as a membership check
or a server-built storage path (12); policies that a later migration drops with dynamic SQL inside
a DO block, which the parser does not execute (7); reads that only decide a 404 or a 409 and are
never returned (9); public-by-design data and forms (7).SECURITY DEFINER functions that
trust a caller-supplied user id, USING (true) policies, admin gates on a role users can write to
their own row, service-role routes with no login at all. Real findings came from 12 of the 22
repositories in the sample. Critical findings were right 28% of the time, high ones 47%.Live numbers, including every sample so far: auditai.sh/stats. This is
why the hosted product proves before it blocks: a likely finding is a lead, not a verdict.
Checked N risks in class authorization/RLS. Verified: X. Confirmed: Y. Unverified: Z.npx auditai-scan [path] [--json] [--fail-on <status>] [--migrations <dir>]...
npx auditai-scan --snapshot-query print the read-only query to run in your SQL editor
npx auditai-scan --snapshot <file> [--json] scan your live database from that query's result
--json machine-readable output
--fail-on <status> exit 1 when a finding reaches this status (default: confirmed)
one of: candidate, likely, confirmed, verified. Leads never do.
--migrations <dir> extra directory with Supabase migration SQL (repeatable)
--snapshot <file> the JSON your SQL editor returned for --snapshot-query ("-" reads stdin)
Exit code: 0 clean, 1 a finding reached --fail-on, 2 usage error or <path> is not a directory
Project config in audit.config.json at the scanned root:
{
"ignore": ["evals/**"],
"migrations": ["supabase/migrations"],
"publicTables": ["products"]
}
This repository carries its own audit.config.json that excludes evals/**: the fixtures are
deliberately vulnerable demo apps, so scanning the repository as a project would report them as
findings. The eval gate scans each fixture directly and is not affected by that file.
publicTables names tables that are public by design (a catalogue, a blog). A service-role read of
such a table is still listed, as suppressed with the declaration printed next to it, so the choice
stays visible in every report; writes to the table are never covered by the declaration.
Suppress a finding you have reviewed, with a reason that stays in the code:
// auditai:ignore supabase.service-role-query-without-authentication -- public waitlist insert; table has RLS with no read policy
export async function POST(req: Request) { ... }
CI gate (GitHub Actions):
- run: npx auditai-scan . --migrations supabase/migrations --fail-on likely
source files ──► parser ──► Program Security Graph ──► rules ──► findings + coverage ──► fixes
(TS compiler API) Route → Handler → Query → Client / Table → RLSPolicy
service_role, anon, user_scoped) and every Drizzle or
Prisma connection (direct_db: RLS never runs for it), follows calls from the
entry point into helpers, service classes and workspace packages (tsconfig paths, package.json
exports) three levels deep, tracks which arguments carry user input and which values come from
the caller's own identity, recognises guards (session, credential and secret checks, ownership
reads, layout gates), and reads tables, columns, policies, grants, functions, triggers and storage
buckets from migration SQL.Repository content under analysis is untrusted data. A comment saying "ignore previous instructions" is just a comment.
Thirty-nine fixture pairs today, growing with every rule. The vulnerable app must fire exactly the expected
rule; the secure twin must produce zero findings. npm run evals enforces both on every commit.
| # | Fixture | Rule exercised |
|---|---|---|
| 001 | cross-tenant-invoice-read | service-role-object-access-without-tenant-scope |
| 002 | rls-disabled-invoices-read | table-without-rls |
| 003 | rls-policy-using-true | rls-policy-without-caller-predicate |
| 004 | user-controlled-tenant-id | user-controlled-tenant-scope |
| 005 | role-from-user-metadata | role-check-from-user-metadata |
| 006 | service-role-key-in-client | service-role-key-exposed-to-client |
| 007 | admin-route-without-auth | service-role-query-without-authentication |
| 008 | mass-assignment-profile-update | mass-assignment-from-request-body |
| 009 | unprotected-server-action | service-role-object-access-without-tenant-scope |
| 010 | batch-lookup-by-ids | service-role-object-access-without-tenant-scope |
| 011 | monorepo-package-client-helper-query | service-role-object-access-without-tenant-scope, through a workspace package and a helper |
| 012 | wrapped-action-module-client-helper | service-role-object-access-without-tenant-scope, wrapped action and module-level client |
| 013 | drizzle-direct-db-invoice-read | service-role-object-access-without-tenant-scope, Drizzle direct connection |
| 014 | prisma-direct-db-invoice-read | service-role-object-access-without-tenant-scope, Prisma direct connection |
| 015 | storage-download-user-supplied-path | storage-object-access-without-owner-scope |
| 016 | storage-policy-bucket-only | storage-policy-without-owner-check |
| 017 | security-definer-rpc-without-caller-check | security-definer-function-without-caller-check, called via supabase.rpc() |
| 018 | security-definer-granted-to-anon | security-definer-function-without-caller-check, executable by anon (critical) |
| 019 | ssr-cookie-admin-project-read | service-role-object-access-without-tenant-scope, cookie auth + a separate admin client |
Each fixture also carries the security-test the hosted product runs in a sandbox: DENY tests
are the security assertion (Alice must not read Bob's row), ALLOW tests are the sanity check
(Alice still reads her own).
npm ci
npm run build # tsc -b
npm test # unit tests + eval gate
npm run lint # biome
npm run bundle # single-file CLI in npm/dist/auditai-scan.mjs
See CONTRIBUTING.md for how to add a rule (always with a fixture pair) and SECURITY.md for reporting vulnerabilities.
createClient, shared query builders).Wider language and framework support is out of scope until this stack is covered deeply.
Apache-2.0. Built in public by audit0. Product: auditai.sh. Security reports: [email protected].
supabase.view-runs-with-owner-rights | lead | high, medium when only signed-in users may select it | View created without security_invoker over a table with RLS: it runs as its owner, so the table's policies never apply inside it and every row is readable through the view (Supabase lint 0010) |
supabase.service-role-key-exposed-to-client | headline | critical | Service-role key reaches the browser (NEXT_PUBLIC_*, client components) |
supabase.service-role-object-access-without-tenant-scope | lead | critical | Service-role client, or a direct Drizzle/Prisma connection, reads or writes a row by user-supplied id without tenant scope (IDOR / BOLA) |
supabase.user-controlled-tenant-scope | lead | critical | Tenant scope comes from the request (body, query, params) instead of the session |
supabase.service-role-query-without-authentication | lead | critical | Route or server action queries with the service role and never checks the caller |
supabase.storage-object-access-without-owner-scope | lead | critical | Storage download/upload/signed URL/move/remove through the service role on a caller-supplied path that is never tied to the caller's user id |
supabase.rls-policy-without-caller-predicate | lead | high | RLS policy grants rows without referencing the caller (using (true) and friends) |
supabase.mass-assignment-from-request-body | lead | high | Request body written to a table without an allow-list, where RLS does not already refuse the write |
supabase.role-check-from-user-metadata | lead | high | Authorization decided by user_metadata, which the user can edit |
supabase.storage-policy-without-owner-check | lead | high | Policy on storage.objects that only checks bucket_id: every user reads, overwrites or deletes every file in the bucket |
supabase.dynamic-sql-from-function-parameter | lead | critical if SECURITY DEFINER and anon can execute, high for signed-in only, medium for SECURITY INVOKER | Function callable through supabase.rpc() glues a text parameter into the SQL it runs with EXECUTE (||, format('%s')), instead of USING or format('%L' / '%I') |
supabase.server-trusts-unverified-session | lead | high | A handler decides access from supabase.auth.getSession(), which reads the cookie without revalidating it, instead of getUser() or getClaims() |
@supabase/ssr| 020 | formdata-server-action-delete | service-role-object-access-without-tenant-scope, id read with formData.get() into a local const |
| 021 | dynamic-page-admin-project-read | service-role-object-access-without-tenant-scope, a server-rendered dynamic page as the entry point |
| 022 | hand-rolled-validator-tenant-scope | user-controlled-tenant-scope, tenant id through a hand-written (no-zod) request validator |
| 023 | mass-assignment-profile-role-cookie | mass-assignment-from-request-body, survives a correct RLS update policy |
| 024 | drizzle-task-update-without-org-scope | service-role-object-access-without-tenant-scope, Drizzle direct connection, write path |
| 025 | prisma-delete-note-without-owner-scope | service-role-object-access-without-tenant-scope, Prisma direct connection, server action |
| 026 | rls-policy-missing-owner-predicate | rls-policy-without-caller-predicate, role-only predicate |
| 027 | documents-table-without-rls | table-without-rls |
| 028 | admin-users-list-role-from-user-metadata | role-check-from-user-metadata |
| 029 | rls-policy-member-helper | rls-policy-without-caller-predicate, policy scoped through a helper function |
| 030 | cron-secret-operator-route | service-role-query-without-authentication, secure twin gated by a cron secret instead of a session |
| 031 | guard-read-then-service-role-delete | service-role-object-access-without-tenant-scope, secure twin checks ownership with an RLS-scoped guard read |
| 032 | mass-assignment-map-spread-messages | mass-assignment-from-request-body, .map() spread of request rows vs explicit fields |
| 033 | do-block-rls-literal-loop | table-without-rls, RLS switched on in a DO $$ foreach ... loop execute format(...) block over a literal list |
| 034 | public-table-declaration | service-role-query-without-authentication, read of a table declared in audit.config.json publicTables is suppressed (visibly), the write path is not |
| 035 | identity-derived-row-id | service-role-object-access-without-tenant-scope, a row id from the caller's own credential lookup vs one from the query string |
| 036 | ownership-guard-shapes | service-role-object-access-without-tenant-scope, ownership via the parent row, a comparison in code, or a conditional owner filter |
| 037 | policy-trusts-user-metadata | rls-policy-trusts-user-metadata, the admin claim read from user_metadata vs app_metadata |
| 038 | policies-without-rls-enabled | policies-without-rls-enabled, four policies on a table whose RLS was never switched on |
| 039 | anon-write-policy | anon-write-policy, an open delete policy for anon next to a deliberate public insert |
| 045 | dynamic-sql-from-function-parameter | dynamic-sql-from-function-parameter, search text glued into EXECUTE vs bound with USING, in a definer function that does check the caller |
| 046 | view-runs-with-owner-rights | view-runs-with-owner-rights, a per-tenant view created the default way vs with (security_invoker = on) |
| 047 | self-assignable-role-column | self-assignable-role-column, an own-row update policy without WITH CHECK vs UPDATE revoked and granted back on full_name only |
| 048 | role-from-signup-metadata | role-from-signup-metadata, handle_new_user copies the requested role vs an allow-list with a safe default |
| 049 | self-writable-entitlement-column | self-writable-entitlement-column, an own-row update policy without WITH CHECK over profiles.credits that a paid export checks vs UPDATE revoked and granted back on full_name only |