This is the whole report.
Below is an unedited Sentris scan, rendered by the same code that renders a paying customer's. Nothing is trimmed for the screenshot and nothing is held back: what you are looking at is what lands in your dashboard.
- Repository
- sentris/demo-vulnerable-app
- Scanned
- 14 August 2026
That repo is ours and it is broken on purpose: it is one of the fixtures every rule check is tested against on every commit. We do not publish customer findings, so the only report we can show you in full is one about a repo we own.
Security grade
At least one critical exposure.
9 exposures · 3 critical
5 confirmed : we read the thing itself, not a pattern that resembles it. 4 potential: the check is missing; whether it is reachable is not proven.
- criticalC2 · confirmed
Stripe live secret key exposed in your repository
sk_live_••••ABCD: .env
Fix
Remove the Stripe secret key (sk_live_…) from client code. Use the publishable key (pk_live_…) in the browser and keep sk_live_ server-side only. Roll it now: Stripe Dashboard → Developers → API keys → Roll key. - criticalC1 · confirmed
Policy "anyone can write orders" on public.orders allows unrestricted access
create policy "anyone can write orders" on public.orders for all using (true);
Fix
A for all policy with true lets anyone holding the anon key write this table. Scope it to the row owner: drop policy "anyone can write orders" on public.orders; create policy "anyone can write orders" on public.orders for all to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id);
- criticalC1 · potential
Table public.leads has no Row Level Security
create table public.leads ( id uuid primary key, email text, phone text );, no "enable row level security" for public.leads anywhere in your SQL
Fix
Tables in the public schema are readable through your project's anon key unless RLS is on. Enable it and add a policy that scopes rows to their owner: alter table public.leads enable row level security; create policy "Owners read their own rows" on public.leads for select to authenticated using (auth.uid() = user_id); If you already enabled RLS from the Supabase dashboard, add the statement to a migration anyway so the next deploy cannot silently drop it.
- highC8 · confirmed
.env is committed to the repository
An environment file with non-placeholder values is tracked in git.
Fix
Assume every value in this file is public. It is in every clone, every fork, and in the history of anyone who has ever pulled, deleting the file now does not change any of that. 1. Rotate every credential in it. This is the step that actually closes the hole; the rest is hygiene. 2. Stop tracking it and keep the local copy: git rm --cached .env echo ".env*.local\n.env" >> .gitignore git commit -m "stop tracking .env" 3. Commit a `.env.example` with the KEYS and no values, so the next person knows what to set without being handed your secrets. 4. Purging history (`git filter-repo`, BFG) is optional and rewrites every hash. Do it if the repo is public, but only after step 1, never instead of it. - highC3 · potential
API route src/app/api/orders/[id]/route.ts takes a caller-supplied id with no auth check
Route handler reads a caller-controlled identifier and queries data, and no authentication or ownership check appears in the file, while using a service_role client that bypasses RLS
Fix
Static analysis cannot prove this is exploitable: it can only show the check is missing. Verify by calling the route as user A with user B's id. Identify the caller and scope the query to them: const { data: { user } } = await supabase.auth.getUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const { data, error } = await supabase .from("items") .select("*") .eq("id", id) .eq("user_id", user.id) // ownership, not just the id from the URL .single(); This route uses a service_role client, which ignores RLS: the ownership check here is the ONLY thing standing between a caller and every other user's rows. Prefer a request-scoped anon client so RLS still applies.
- highC7 · potential
API route src/app/api/profile/route.ts writes the whole request body to the database
`body`, the parsed request body, is passed straight to a write on profiles, so every column the caller includes in the JSON is set, not only the ones the form sends
Fix
Verify by sending a column the form never offers: `{"role":"admin"}`, `{"is_admin":true}`, `{"credits":999999}`, `{"user_id":"<someone else>"}`, and reading the row back. Being allowed to update a row is not the same as being allowed to update every column of it, and Row Level Security does not draw that line: a policy that says "you may update your own row" says nothing about which fields you may touch. Name the columns you accept, and take the rest from the server: import { z } from "zod"; const Body = z.object({ display_name: z.string().min(1).max(80), bio: z.string().max(500).optional(), }).strict(); // .strict() rejects anything else instead of ignoring it const parsed = Body.safeParse(await req.json()); if (!parsed.success) return NextResponse.json({ error: "Bad request" }, { status: 400 }); await supabase .from("profiles") .update(parsed.data) // only the fields above .eq("id", user.id); // the row, decided by the session Destructuring works just as well as a schema: `const { display_name, bio } = await req.json()` is an allowlist too. What must not happen is the parsed object reaching the write untouched.
- highC6 · potential
Function public.get_orders_for_user runs with its owner's rights and never checks the caller
create or replace function public.get_orders_for_user(p_user_id uuid) returns setof orders language plpgsql se: no auth.uid(), auth.jwt() or equivalent anywhere in the function
Fix
Verify by calling it with nothing but your project's anon key: curl -X POST "https://<ref>.supabase.co/rest/v1/rpc/get_orders_for_user" \ -H "apikey: <anon key>" -H "Content-Type: application/json" -d '{}' Every function in the public schema is an endpoint. `security definer` makes it run as its owner, so Row Level Security on the tables it reads does not apply to whoever called it: the policies are still there, they just are not being asked. Derive the identity inside the function instead of trusting an argument: create or replace function public.get_orders_for_user(...) returns setof orders language sql security definer set search_path = '' as $$ select * from public.orders where user_id = (select auth.uid()); -- the caller, not an argument $$; Then take away the reach it does not need: revoke execute on function public.get_orders_for_user from anon; If this function is only ever called by a trigger or from your own server with the service_role key, the revoke alone closes it.
- mediumC4 · confirmed
Storage bucket "invoices" is public
insert into storage.buckets (id, name, public) values ('invoices', 'invoices', true);
Fix
Every object in a public bucket is readable by anyone with the URL, no key, no login. That is correct for avatars and marketing assets, and a leak for anything user-owned (uploads, documents, invoices). If it should be private, flip the bucket and serve files through signed URLs: update storage.buckets set public = false where id = 'invoices'; create policy "Owners read their own files" on storage.objects for select to authenticated using (bucket_id = 'invoices' and owner = auth.uid()); Then hand out links with createSignedUrl(path, 60) instead of getPublicUrl(path).
- mediumC17 · confirmed
Storage bucket "invoices" accepts any file type and any size
the insert into storage.buckets lists neither file_size_limit nor allowed_mime_types, so the storage service enforces neither. Any caller holding the anon key can upload anything, of any size, directly, no route handler of yours is involved.
Fix
Set both limits on the bucket, where they are enforced on every path in: update storage.buckets set file_size_limit = 5242880, -- 5 MB allowed_mime_types = array['image/png','image/jpeg','image/webp'] where id = 'invoices'; Or in supabase/config.toml, so a fresh environment gets the same rules: [storage.buckets.invoices] public = false file_size_limit = "5MiB" allowed_mime_types = ["image/png", "image/jpeg", "image/webp"] Three things the mime list does not do, and that you still want: · It trusts the Content-Type the uploader sends. Check the file's magic bytes server-side if the content matters. · It does not stop `image/svg+xml` from containing script. Leave SVG off the list unless you need it, and serve it from a bucket you do not link to from your own origin. · It does not choose the filename. Generate the storage path yourself (`${user.id}/${crypto.randomUUID()}.png`): a caller-supplied name is how one user overwrites another's file.
Checked
- · Row Level Security on every table in your public schema, and policies that grant unrestricted access via using (true)
- · Hardcoded secrets committed to the repo: service_role, Stripe, and AI-provider keys, including .env files
- · API routes that take a caller-supplied id with no auth check (reported as potential: two requests with your own throwaway test accounts turn it into a proof)
- · Supabase storage buckets marked public in config.toml, a migration, or a createBucket() call
Out of scope
- · Server infrastructure & DDoS
- · Dependency CVEs
- · TLS / cryptography depth
- · Social engineering
- · Business logic beyond access control
- · Mobile apps
- · Load / performance
Point-in-time, findings-based results. Sentris is not a penetration test, not a certification, and carries no warranty. We report reproducible exposures with evidence and a fix: we prove them, we do not exploit them.
Now run it on yours.
Scanning is free and takes no login. You see how many exposures you have before you pay for anything.
Scan my app