Skip to content
SoloShow

Real projects from people who build alone, with the story behind each one: why they made it, what it cost, what they used, and whether it is still running.

Vibe coding security: the holes I found in my own AI-built app

Five security holes in SoloShow, a site I built with Claude Code: what each one was, how I found it, and the fix. Plus the checks I now run on every build.

By · · Facts last checked

AI coding tools are good at making a feature work. The security holes sit in what nobody asked for: who else can call the same endpoint, which columns a row-level policy actually covers, where a URL leads after a redirect. I built SoloShow with Claude Code and found 5 holes in its first month, all before launch. Here's each one, how it got in, and the fix.

1. Any signed-in user could make themselves an admin

SoloShow's profiles table had a Row Level Security policy letting each user update their own row. Sensible. But is_admin lived on that same row, and Supabase grants the authenticated role UPDATE on every table in the public schema by default.

So anyone with an account could send one request to the database API, with their own session, setting is_admin to true on their own profile. I tested it against the live API before fixing it. It returned 200 and the user was an admin, with the moderation queue and every unreviewed submission open to them.

The policy itself read correctly, which is why it got through. Row Level Security filters rows, and columns need rules of their own.

The fix is a column grant:

revoke update on profiles from authenticated, anon;
grant update (handle, display_name, bio, profile_url, avatar_url)
  on profiles to authenticated;

The database API now refuses any update that includes a column outside that list, even if the rest of the payload is fine.

2. Builders could approve their own submissions

The same shape turned up later on the builds table. A builder could edit their own build, which they need to. Edit meant every column, status included, so a builder could set their own submission to approved and skip moderation. They could also insert a new build as approved in the first place.

This time the fix had two parts. Builders get UPDATE and INSERT on the content columns only. Moderation moved into a database function that checks who is calling before it changes anything:

create or replace function moderate_build(target uuid, decision build_status, reason text default null)
returns void
language plpgsql security definer set search_path = public as $$
begin
  if not exists (select 1 from profiles where id = auth.uid() and is_admin) then
    raise exception 'not allowed' using errcode = '42501';
  end if;
  -- update status, reject_reason, first_verified_at
end $$;

The admin screen calls that function. No signed-in user can write status any other way.

3. An approved listing could swap its link

With approval locked down, the next gap was what happened after it. A builder could get a real product approved, then edit the listing and change its URL to anything. The edit form kept the build live, so the page would now send visitors somewhere nobody had reviewed.

I fixed it in the database with a trigger that runs on every update by a signed-in user. A new URL on an approved build sends it back to pending and takes it off the site until I've looked. Text edits stay live but get flagged in the moderation queue. Editing a rejected build resubmits it.

It lives in the database because the database API is reachable directly. Anything a form enforces, someone with a session and curl can skip.

4. The URL checker could be pointed inside the network

SoloShow fetches every submitted URL. It checks the site is up when it's submitted and again every week, and takes a screenshot of it with headless Chrome. Any feature like that can be turned into a way to make your server request addresses the attacker can't reach, like the cloud metadata service at 169.254.169.254.

The checker blocked the obvious ones (localhost, 10.x, 192.168.x). Writing tests for it turned up 3 ways round the list:

  • Redirects. The fetch followed redirects on its own, so only the first address was ever checked. A harmless-looking URL that redirected to 169.254.169.254 passed.
  • IPv4 dressed as IPv6. http://[::ffff:127.0.0.1]/ reaches this machine, but the URL parser hands the host over as ::ffff:7f00:1, which matched none of the IPv4 patterns.
  • 0.0.0.1 and [::], which the Linux network stack treats as this machine too.

The fix follows redirects by hand, up to 5, and puts every hop through the same check. Mapped IPv6 addresses get turned back into IPv4 before the check runs. The screenshot browser intercepts every request it makes and refuses private addresses the same way.

One gap is still open and on the list: a public hostname whose DNS answers with a private address passes a check that only reads the name. Closing it means resolving the name first and checking the IP.

5. The framework itself had a critical advisory

The last one was in code I hadn't written at all. When I added npm audit to the test pipeline, it failed on the first run: the version of Next.js the live site was running had published advisories rated critical, among them a server-side request forgery in image optimisation and cache poisoning on statically generated pages. The image library had a high-rated one too.

The fix was a version bump. Finding out took a check I hadn't been running.

How I found them

Mostly by asking 2 questions of every feature: who else can call this, and what does it touch that it didn't mean to? The answers were in the database grants and the network requests more often than in the application code.

Then I wrote each attack down as a test. SoloShow's permission tests run as a real signed-in user inside a transaction that rolls itself back, so they can run against the live database without changing anything. Each of the first 4 holes now has a test that tries the attack, and the fifth is caught by the audit step in the pipeline, so none of them can quietly come back.

The checks I now run on every AI-built app

List what each role can write. For any Supabase project, run this and read every row:

select grantee, table_name, column_name
from information_schema.column_privileges
where table_schema = 'public'
  and privilege_type = 'UPDATE'
  and grantee in ('anon', 'authenticated')
order by table_name, column_name;

Keep trust decisions behind a function. Status, roles, verification flags and anything else that decides what other people see should only be written by a function that checks who's calling.

Enforce rules where the data lives. If the database API is public, a rule that lives only in a form can be skipped by anyone with a session. Use grants, policies and triggers.

Treat every fetched URL as hostile. Block private ranges, check every redirect hop, and handle the IPv6 forms.

Audit what you didn't write. Run npm audit --omit=dev in your pipeline and fail on high or critical.

Every one of these came down to asking the questions the AI wasn't asked. If you're building alone, the launch checklist covers the rest of what to do before you go live, and the Supabase auth guide covers setting up sign-in properly.

Questions

Is vibe-coded software less secure than hand-written code?
It fails in a different place. AI tools are good at making the feature you asked for work, so the obvious path is usually fine. The holes sit in what nobody asked about: which other people can call the same endpoint, which columns a policy protects, where a URL goes after a redirect. You have to ask those questions yourself.
Does Supabase Row Level Security protect individual columns?
Row Level Security decides which rows a role can read or change. Once a row passes the policy, every column on it is writable unless you restrict columns separately. Postgres column privileges do that: revoke the table-wide UPDATE from the authenticated role, then grant UPDATE only on the columns users should edit.
How do I see which columns a Supabase role can update?
Query information_schema.column_privileges in the SQL editor, filtered to the public schema, the UPDATE privilege and the anon and authenticated roles. Every row in the result is a column that a signed-in user, or an anonymous visitor, can write through the API. Anything that decides trust, like status or is_admin, should not appear.
What is SSRF and why does it matter for a side project?
Server-side request forgery is getting your server to fetch an address the attacker can't reach directly, such as the cloud metadata service at 169.254.169.254. Any feature that fetches a user-supplied URL is exposed: link checkers, screenshot tools, unfurlers, webhooks. Block private addresses and check every redirect hop, since a public URL can redirect inward.
Should I ask the AI to fix the security issues it created?
Yes, once the problem is named precisely. Claude Code fixed every hole here quickly, and found 2 of them itself while working on nearby features or reviewing its own changes. The rest turned up when it was asked to look. Ask for a test that tries the attack first, check that it fails, then ask for the fix and watch it pass.

Tools covered here