Real scanEvery line below is the unedited output of one real Deep Scan, run on 22 September 2026 against DemoCart — our own deliberately vulnerable test app, fake data only. It has 24 planted bugs; this scan found 24. DemoCart is not a real company or a real customer.
Anyone can open /_next/static/chunks/main-abc123.js in a browser and read 3 secret keys from it: Stripe secret/restricted key, OpenAI-style API key, Hardcoded Bearer token. These are server-side credentials — whoever has them can spend your money or read your data directly, without touching your app at all.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Fix prompt — paste into Cursor / Claude Code / Lovable
My production JavaScript bundle `/_next/static/chunks/main-abc123.js` contains live secrets:
- Stripe secret/restricted key (starts "sk_l…(49 chars)")
- OpenAI-style API key (starts "sk-p…(45 chars)")
- Hardcoded Bearer token (starts "FIXT…(35 chars)")
Do all of the following:
1. Treat every key above as compromised — rotate each one in its provider dashboard NOW.
2. Find where each value is read in my code. Any secret used from the browser must move to a
server route / server action / edge function; the browser calls my route, my route calls
the provider with the secret from a server-only env var.
3. Remove the values from all client-visible env vars. In Next.js only NEXT_PUBLIC_* reaches
the browser, so no secret may ever be named NEXT_PUBLIC_* (Vite: VITE_*, CRA: REACT_APP_*).
4. Purge the values from git history and from my hosting provider's env config.
5. Add a CI step that greps the built bundle for these patterns and fails the build.
Show me the diff for each moved secret.
S1:/_next/static/chunks/main-abc123.js
S2
Supabase service_role key is shipped to the browser
CriticalVerified
/_next/static/chunks/main-abc123.js contains a Supabase key with the `service_role` claim (eyJh…(177 chars)). That key ignores Row Level Security, so anyone who views source can read, change or delete every row and every uploaded file in your project — customer data included — without logging in. Solacess did not use the key: it is reported, never spent.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Fix prompt — paste into Cursor / Claude Code / Lovable
My production bundle `/_next/static/chunks/main-abc123.js` contains a Supabase key whose JWT `role` claim is
`service_role`. That key bypasses Row Level Security on every table and storage bucket.
Do all of the following:
1. In the Supabase dashboard (Project Settings -> API), roll the service_role key NOW. The
exposed one must be assumed to be in someone else's hands.
2. Grep my repo for the old value and for the names SUPABASE_SERVICE_ROLE_KEY /
SERVICE_ROLE / service_role. Any use inside a client component, a `use client` file, or a
NEXT_PUBLIC_* env var is the bug.
3. The browser must only ever see the anon key. Move every service_role call into a server
route, server action, or edge function that reads `process.env.SUPABASE_SERVICE_ROLE_KEY`
on the server only, and add my own authorization check inside it.
4. Because the key bypassed RLS, verify each table actually has RLS enabled with real
policies — code may have been relying on the key instead of on policies.
5. Purge the value from git history and from the hosting provider's env config.
Show me the diff and the list of files that used the service_role client.
S2:/_next/static/chunks/main-abc123.js
D1
Anyone can read and write the `feedback` table
CriticalVerified
We asked your database for the `feedback` table using only the public key that ships in your JavaScript bundle — no login, no password — and it returned 2 rows. We were also able to insert a row, which no deliberate public-read policy would allow: Row Level Security is off on this table, so anyone who views source can download it and write to it. Treat every row in it as public.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Fix prompt — paste into Cursor / Claude Code / Lovable
Anyone with my Supabase anon key (which is in my JS bundle, as it is meant to be) can
read AND insert rows in the `feedback` table.
Do all of the following:
1. Turn RLS on: `alter table public.feedback enable row level security;` — and remember that
enabling RLS with no policy denies everything, which is the correct starting point.
2. Add explicit policies for what this table is actually for. For a per-user table:
```sql
create policy "feedback_select_own" on public.feedback
for select to authenticated using ((select auth.uid()) = user_id);
create policy "feedback_insert_own" on public.feedback
for insert to authenticated with check ((select auth.uid()) = user_id);
```
Give each policy an explicit `to` role — a policy with no role applies to anon as well.
3. If part of this table really is public, write a policy that says so for exactly the rows
that are public (`for select to anon using (published = true)`), rather than leaving the
whole table open.
4. Add an index on the column the policy filters on (e.g. `feedback(user_id)`) so the policy
is not a sequential scan on every read.
5. Run the same check over EVERY table:
`select relname, relrowsecurity from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r';`
Anything with relrowsecurity = false has exactly this hole.
6. Delete any row in `feedback` containing the text "solacess-scan-probe: safe to delete" — Solacess inserted one to prove the table was writable.
Show me the SQL migration.
D1:feedback
D2
Logged-in users can read each other's rows in `notes`
CriticalVerified
We signed in as two different test accounts and asked for the `notes` table. rows belonging to 2 different accounts came back for one account (1 row that should not have been visible). Row Level Security is switched on for this table, which is why it looks safe in the dashboard — but the policy grants access to everyone who is logged in rather than to the row's owner. Any customer can read every other customer's data.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Affected
notes
Proof (redacted)
REQUESTGET http://127.0.0.1:14611/rest/v1/notes (as A)
apikey: [REDACTED]
Fix prompt — paste into Cursor / Claude Code / Lovable
My `notes` table has RLS enabled, but the policy lets any logged-in user read other
users' rows. Two different test accounts were served the same rows.
Do all of the following:
1. List the policies: `select policyname, cmd, roles, qual, with_check from pg_policies
where tablename = 'notes';`. Any `qual` of `true` (or a `using (true)`) is the bug —
that clause is the entire access check and it always passes.
2. Replace it with an owner check, one policy per command, each with an explicit role:
```sql
drop policy if exists "<the broad policy>" on public.notes;
create policy "notes_select_own" on public.notes
for select to authenticated using ((select auth.uid()) = userId);
create policy "notes_update_own" on public.notes
for update to authenticated using ((select auth.uid()) = userId)
with check ((select auth.uid()) = userId);
```
Use `(select auth.uid())` rather than bare `auth.uid()` so Postgres evaluates it once per
query instead of once per row.
3. `insert` policies need `with check`, not `using` — a `using`-only insert policy allows
anything.
4. Index the owner column: `create index if not exists notes_userId_idx on public.notes (userId);`
5. Add a test that signs in as user B and asserts that a row owned by user A is NOT returned.
Repeat for every table with a per-user policy.
Show me the SQL migration and the test.
D2:notes
D5
Anyone can run the `promote_to_admin` database function
CriticalVerified
We called the database function `promote_to_admin` with nothing but the public key from your JavaScript bundle — no login — and your database ran it (HTTP 200) instead of refusing. Functions like this are written with `security definer`, which means they ignore your Row Level Security rules by design, so this one is a way around every policy you have. Solacess called it with an id that does not exist, so nothing in your data changed — but the same call with a real id would have worked.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Fix prompt — paste into Cursor / Claude Code / Lovable
My database function `promote_to_admin` can be executed by anyone holding the public anon key — which
is everyone, because that key ships in my JavaScript bundle.
Do all of the following:
1. Take the grant away. In Postgres, EXECUTE on a function is granted to PUBLIC by default,
so creating the function was enough to expose it:
```sql
revoke execute on function public.promote_to_admin from public, anon, authenticated;
```
Then grant it back only to whoever legitimately needs it (often nobody: call it from a
server route with the service_role key instead).
2. Check whether `promote_to_admin` needs `security definer` at all. If it only touches the caller's own
rows, `security invoker` (the default) plus RLS is the safer design.
3. If it must stay `security definer`, make it check the caller itself, first line, and pin
its search_path so it cannot be tricked into calling something else:
```sql
create or replace function public.promote_to_admin(target_user_id uuid)
returns void language plpgsql security definer set search_path = '' as $$
begin
if not exists (select 1 from public.profiles
where user_id = (select auth.uid()) and is_admin) then
raise exception 'not authorized';
end if;
-- ... the actual work ...
end; $$;
```
4. Audit EVERY function the same way:
`select p.proname, p.prosecdef, pg_get_functiondef(p.oid)
from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public';`
Anything with prosecdef = true and no caller check inside it is the same bug.
5. Check your data for damage already done — admins you did not create, credits you did not
sell — because this function has been callable by the public for as long as it has existed.
Show me the SQL migration.
D5:rpc/promote_to_admin
A1
One user can read another user's private records
CriticalVerified
We logged in as one test account and requested a record that belongs to the other test account (`GET /api/invoices/inv_a_1`). The API returned it in full. That means any of your users can read any other user's data just by changing the id in the URL — invoices, messages, uploads, anything behind `/api/invoices/:id`.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Fix prompt — paste into Cursor / Claude Code / Lovable
Any logged-in user can read another user's data by calling `/api/invoices/:id` with someone
else's id. There is no ownership check on the object.
Fix it:
1. In the handler, after authenticating the caller, load the record and verify it belongs to
the caller (e.g. `where id = :id AND user_id = auth.uid()`), or return 403/404 if not.
2. Do this for every `/:id` route, not just this one — list, get, update and delete.
3. If the data lives in Supabase, add a Row Level Security policy so the database enforces
ownership even if a route forgets: `using (user_id = auth.uid())`.
4. Never trust an id from the client to imply permission; the session decides what is visible.
5. Add a test: account B requesting account A's object id must get 403 (or 404), never 200.
Show me the handler and policy diff.
A1:/api/invoices/:id
A2
Private data is served to anyone, no login required
CriticalVerified
We called `GET /api/invoices` with no login — no cookie, no token — and your API returned private account data anyway (the same route returns a specific user's records when they are logged in). Anyone with the URL can read this, no account needed.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
RESPONSEHTTP 200 (1ms)
connection: keep-alive
content-type: application/json; charset=utf-8
date: Tue, 22 Sep 2026 06:01:16 GMT
keep-alive: timeout=5
transfer-encoding: chunked
{"invoices":[{"id":"inv_a_1","userId":"user_a_1111","amountCents":4900,"description":"Pro plan - Sep"},{"id":"inv_a_2","userId":"user_a_1111","amountCents":4900,"description":"Pro plan - Aug"},{"id":"inv_b_1","userId":"user_b_2222","amountCents":1900,"description":"Basic plan - Sep"}]}
Fix prompt — paste into Cursor / Claude Code / Lovable
The API route `/api/invoices` returns data to a caller with no session at all — it works logged
out. Anyone on the internet can read it.
Fix it:
1. Require authentication on this route (and every `/api/*` route that returns user data).
In Next.js, check the session at the top of the handler / in middleware and return 401
when there is none.
2. After authenticating, scope the query to the caller (`where user_id = auth.uid()`), so it
can never return another user's rows.
3. If the data is in Supabase, turn on Row Level Security for the table so the database
refuses anonymous reads even if a route is missed.
4. Add a test: this route with no cookie / no Authorization header must return 401.
Show me the route and middleware diff.
A2:/api/invoices
A3
A normal user can reach admin-only endpoints
CriticalVerified
We read this test account's own profile first and confirmed it is NOT an admin, then called the admin endpoint `GET /api/admin/users` directly as that account. It responded (HTTP 200) even though an anonymous call is rejected — and the response body carries our other test account's user id (`user_a_1111`), which this caller has no relationship to. So the account did not just see its own data: it reached administrative data that belongs to someone else. The admin check only lives in the UI — hiding the button — so any logged-in user who calls the API by hand gets in.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Fix prompt — paste into Cursor / Claude Code / Lovable
A normal, non-admin user can call your admin endpoint `/api/admin/users` directly and it responds.
The admin check only exists in the UI (hiding the button), not in the API.
Fix it:
1. On every `/api/admin/*` route, verify the caller's role on the SERVER before doing
anything: load the user, check `role === 'admin'` (or your admin flag), else return 403.
2. Do not rely on the frontend hiding the link — assume every route can be called directly.
3. Keep the admin flag in a place the user cannot edit (see the profiles / self-role check).
4. Add a test: a normal user's session calling this route must get 403.
Show me the admin route guard diff.
A3:/api/admin/users
A5
The app accepts forged, unsigned login tokens
CriticalVerified
We sent `GET /api/protected/profile` a login token we forged with a alg:none (unsigned) — the kind of token a real server always rejects — and it was accepted (HTTP 200). Your app trusts whatever a token claims without checking that it is genuine, so an attacker can impersonate any user, including an admin, without a password.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Fix prompt — paste into Cursor / Claude Code / Lovable
Your route `/api/protected/profile` accepted a JWT that was not properly signed — an `alg:none` token
and a token with a broken signature both got in. The server is reading the token's claims
without verifying it, so anyone can mint a token claiming to be any user (including an admin).
Fix it:
1. Verify every JWT with the library's verify function and your secret/JWKS — never decode
the payload and trust it.
2. Pin the algorithm: reject `alg:none` and only allow the algorithm you actually issue
(e.g. HS256 or RS256). A mismatched or missing signature must fail.
3. Check `exp` (and `nbf`/`aud`/`iss` where relevant). Reject expired tokens.
4. If you use Supabase, verify the token against its JWKS / secret rather than trusting `sub`.
5. Add tests: an `alg:none` token, a token signed with the wrong key, and an expired token
must all be rejected with 401.
Show me the token-verification diff.
A5:/api/protected/profile
F2
Any website can call your API with your users' sessions
HighVerified
We called /api/invoices with the header `Origin: https://solacess-cors-probe.example` — an origin that has nothing to do with your app — and your API answered with `Access-Control-Allow-Origin: https://solacess-cors-probe.example` and `Access-Control-Allow-Credentials: true`. That means any page on the internet can make a logged-in user's browser call your API with their cookies and read the response. A single link is enough.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Affected
/api/*
Proof (redacted)
REQUESTGET /api/invoices (as A)
origin: https://solacess-cors-probe.example
Fix prompt — paste into Cursor / Claude Code / Lovable
My API (`/api/invoices`, and by the look of it every `/api/*` route) returns
Access-Control-Allow-Origin echoing back whatever Origin the caller sent
together with Access-Control-Allow-Credentials: true.
Fix it:
1. Replace the reflect-any-origin logic with an explicit allowlist of my own origins
(production domain, staging domain, http://localhost:3000 for dev only).
2. If the request's Origin is not in the allowlist, send NO CORS headers at all — do not send
the origin back, and do not fall back to '*'.
3. Never combine Access-Control-Allow-Origin: '*' with Access-Control-Allow-Credentials: true.
4. Add `Vary: Origin` so a CDN cannot cache one caller's CORS response for another.
5. Handle the OPTIONS preflight with the same allowlist.
6. Add a test: a request with Origin https://evil.example must come back with no
Access-Control-Allow-Origin header.
Show me the middleware/route diff.
F2:cors
D4
Anyone can download files from the `user-uploads` bucket
HighVerified
We downloaded `user_a_1111/resume.pdf` out of your `user-uploads` storage bucket without logging in — no account, no password, just the public key that ships in your JavaScript. The file belongs to one of the test accounts you gave us, and we did not keep a byte of it. Every other file stored the same way is equally reachable by anyone who can guess or is told its path, and paths built from user ids are easy to guess once you have one.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Affected
storage:user-uploads
Proof (redacted)
REQUESTGET /storage/v1/object/user-uploads/user_a_1111/resume.pdf (anon key only, no session)
RESPONSE200 · content-type: text/plain; charset=utf-8 · 17 bytes returned · body deliberately not stored (it is a customer's user's file)
Fix prompt — paste into Cursor / Claude Code / Lovable
Anyone can download `user_a_1111/resume.pdf` from my `user-uploads` storage bucket with no login at all —
it belongs to one of my users and a stranger can read it.
Do all of the following:
1. In the Supabase dashboard (Storage -> user-uploads -> Configuration), turn "Public bucket" OFF
unless every single object in it is genuinely meant for the whole internet.
2. Store objects under a path that starts with the owner's id (`<uid>/<filename>`), then write
policies on `storage.objects` that compare that first path segment to the caller:
```sql
create policy "user-uploads_read_own" on storage.objects
for select to authenticated using (bucket_id = 'user-uploads'
and (storage.foldername(name))[1] = (select auth.uid())::text);
create policy "user-uploads_write_own" on storage.objects
for insert to authenticated with check (bucket_id = 'user-uploads'
and (storage.foldername(name))[1] = (select auth.uid())::text);
```
3. Serve private files through short-lived signed URLs (`createSignedUrl`), not public ones,
and never put a permanent public URL in an email or a webhook payload.
4. Check EVERY bucket, not just this one: `select id, public from storage.buckets;`
5. Assume anything in this bucket has already been downloaded. If it holds documents with
personal data, work out what your breach-notification duty is.
Show me the SQL migration and the bucket settings to change.
D4:user-uploads
S4
A source map is public in production
MediumVerified
One source map is downloadable by anyone, and each one contains your original source code (`sourcesContent`). That hands a stranger your internal routes, feature flags and any value that was inlined at build time — the same view your editor has. First: /_next/static/chunks/main-abc123.js.map.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
RESPONSEHTTP 200 (2ms)
connection: keep-alive
content-type: application/json; charset=utf-8
date: Tue, 22 Sep 2026 06:01:16 GMT
keep-alive: timeout=5
transfer-encoding: chunked
{"version":3,"file":"main-abc123.js","sources":["../../src/app.tsx"],"sourcesContent":["// original fixture source, should not be public in prod"],"names":[],"mappings":"AAAA"}
Fix prompt — paste into Cursor / Claude Code / Lovable
My production build serves source maps publicly:
- /_next/static/chunks/main-abc123.js.map
Do all of the following:
1. Turn off public source maps for the production build. Next.js: make sure
`productionBrowserSourceMaps` is false (the default) in next.config; Vite: set
`build.sourcemap` to false, or to 'hidden' if my error monitor needs maps.
2. If I need maps for Sentry/Bugsnag, upload them at build time and delete them from the
deployed output so they are never fetchable from my domain.
3. Re-deploy, then confirm each path above returns 404.
4. Because my original source was public, treat anything inlined in it as leaked: rotate any
key found in the map and re-check that admin-only routes enforce authorization
server-side, not just by being hard to guess.
Show me the config diff.
S4:/_next/static/chunks/main-abc123.js.map
Money leak
D3
Users can change their own `role` in `profiles`
CriticalVerified
We signed in as an ordinary test account and asked the database to write the `role` column on that account's own row in `profiles`. The write was accepted (HTTP 200). Solacess wrote the value the column already had, so nothing in your data changed — but the same request with `admin`, a paid plan, or a bigger credit balance would have been accepted too. Any customer who opens the browser console can upgrade themselves for free.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Affected
profiles
Proof (redacted)
REQUESTGET http://127.0.0.1:14611/rest/v1/profiles (as A)
apikey: [REDACTED]
Fix prompt — paste into Cursor / Claude Code / Lovable
A logged-in user can write the `role` column on their own row in `profiles`. That means
any customer can grant themselves whatever that column controls — admin rights, a paid plan,
or more credits — straight from the browser console with the public anon key.
Do all of the following:
1. Take the write grant away at the column level. RLS decides WHICH rows; column privileges
decide WHICH columns, and you need both:
```sql
revoke update on public.profiles from authenticated, anon;
grant update (/* only the columns a user may edit, e.g. */ display_name, avatar_url)
on public.profiles to authenticated;
```
2. Belt and braces in the policy as well, so the check survives a future `grant`:
```sql
create policy "profiles_update_own_safe" on public.profiles
for update to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id and role = (select role from public.profiles where user_id = (select auth.uid())));
```
(Or move `role` out of the user-writable table entirely — a separate table only your
server role can write is the cleanest version of this.)
3. `role` should only ever change from trusted code: a database trigger, a server route
that checks the caller, or your Stripe webhook handler. Find every place it is set today
and make sure none of them trusts a value that came from the client.
4. Audit current data for rows that were already tampered with: look for unexpected `role`
values in `profiles` (admins you did not create, paid plans with no matching payment).
5. Add a test: sign in as a normal user, PATCH the column, assert the request fails.
Show me the SQL migration and the test.
D3:profiles.role
P1
Stripe webhook returned 2xx to an event with an invalid signature
CriticalSuspected
We POSTed a webhook event to `/api/webhooks/stripe` with a deliberately invalid `Stripe-Signature` and your server answered HTTP 200 instead of 400. Stripe signs every real event, and a handler that verifies the signature rejects an unsigned one. We sent a harmless no-op event and changed nothing, so we have NOT proven your handler acts on forged events — check whether it verifies the signature before doing any work. If it does not, anyone can forge "payment succeeded" events.
A strong signal, not conclusively reproduced. Treat it as a lead to check yourself.
Fix prompt — paste into Cursor / Claude Code / Lovable
Your Stripe webhook `/api/webhooks/stripe` returned 2xx to an event with an invalid signature. If the
handler acts on events it has not verified, anyone who knows the URL can POST fake payment
events. (We did not change any billing state to prove it, so confirm this yourself.)
Fix it:
1. Verify every webhook with `stripe.webhooks.constructEvent(rawBody, sig, endpointSecret)`
using the raw request body (not the parsed JSON) and your `whsec_...` signing secret.
2. If verification throws, return 400 and do nothing else.
3. If you acknowledge first and process on a queue, verify the signature BEFORE enqueuing.
4. Keep the signing secret server-side only; rotate it if it may have leaked.
5. Add a test: a request with a missing or wrong `Stripe-Signature` must not change any
credits/subscriptions.
Show me the webhook handler diff.
P1:/api/webhooks/stripe
P2
Replaying one payment event credits the account again
CriticalVerified
We sent the same payment event to `/api/webhooks/stripe` twice (crediting a small test amount to a test account we control) and the account's balance went up both times — it moved by 2 for two identical events. Your webhook has no idempotency check, so every retry — or every deliberate replay by an attacker — grants the credits or paid time again.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
$49/mo at risk— Each replay re-applies a paid event at no cost (about $49/mo per replayed subscription event at your top plan), and it can be repeated without limit. Figure shown as one replay per month.
Fix prompt — paste into Cursor / Claude Code / Lovable
Your Stripe webhook `/api/webhooks/stripe` applied the same event twice — it has no idempotency guard.
Stripe retries events, and a replayed request double-credits the account.
Fix it:
1. Before acting on an event, record its `event.id` in a table with a UNIQUE constraint.
If the insert conflicts, you have already processed it — return 200 and stop.
2. Wrap the credit/subscription change and the id insert in one transaction so a retry can
never apply the effect without also recording the id.
3. (Also verify the signature — see the signature finding.)
4. Add a test: posting the same event id twice changes the balance once.
Show me the idempotency diff.
P2:/api/webhooks/stripe
P3
Customers can set their own price at checkout
CriticalVerified
We asked `/api/checkout` to check out a plan your server prices at $19.00, but told it the price was $0.01 — and it charged $0.01. Your checkout trusts the amount the browser sends, so anyone can pay whatever they want for a paid plan.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
$19/mo at risk— Every purchase can be underpaid by $18.99 (we paid $0.01 for a $19.00 plan). Figure shown per purchase; an attacker can repeat it on every checkout.
Affected
/api/checkout
Proof (redacted)
REQUESTPOST /api/checkout (as A, mutating)
{"priceId":"price_basic","quantity":1}
Fix prompt — paste into Cursor / Claude Code / Lovable
Your checkout endpoint `/api/checkout` used a price the browser sent instead of your own price.
A customer can set any amount — we were quoted 1 cent for a paid plan.
Fix it:
1. On the server, look up the amount from the price/plan id in your own catalog (or from
Stripe with `stripe.prices.retrieve`). Never read an amount, unit price, or quantity cap
from the request body.
2. Create the Checkout Session / PaymentIntent with that server-side amount only.
3. Validate the quantity against a sane maximum.
4. Add a test: a checkout request carrying `unitAmountCents: 1` must still be priced from the
server catalog.
Show me the checkout handler diff.
P3:/api/checkout
A4
Free accounts can use paid features
HighVerified
We called the paid endpoint `GET /api/premium/export` with a free-plan test account and it returned the premium result (HTTP 200) — the same as the paying account got. Your paywall is only in the browser, so anyone on the free plan can use paid features without paying.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Fix prompt — paste into Cursor / Claude Code / Lovable
A free-plan account can call your paid endpoint `/api/premium/export` and get the premium result.
The paywall is only enforced in the browser (hiding the feature), not on the server.
Fix it:
1. On every paid endpoint, load the caller's current plan/subscription on the SERVER and
return 402 (Payment Required) or 403 if they are not entitled.
2. Derive entitlement from your billing source of truth (the subscription record / Stripe
status), never from a value the client sends.
3. Do not rely on hiding the button — assume the endpoint is called directly.
4. Add a test: a free account calling this endpoint must get 402/403, a paid account 200.
Show me the entitlement-check diff.
A4:/api/premium/export
P4
Paid access is reported for a subscription that is not active
HighSuspected
We asked `/api/subscription/status` about one of your test accounts (read-only — we changed nothing). It reports the subscription as "none" and, in the same response, reports the account as still having access. Access does not appear to be derived from the real billing state, so accounts that cancel, fail a payment, or never subscribed can keep paid features. We did not cancel a subscription to prove it, so confirm this against your own entitlement logic.
A strong signal, not conclusively reproduced. Treat it as a lead to check yourself.
Fix prompt — paste into Cursor / Claude Code / Lovable
`/api/subscription/status` reports an account as having access while reporting its subscription as
not active. Access is not tied to the real subscription state, so churned users, failed
payments and never-subscribed accounts keep paid access.
Fix it:
1. Derive access from the current subscription/billing record every time — a plan is active
only while its status is `active` or `trialing` and the current period has not ended.
2. Handle Stripe's `customer.subscription.updated`/`deleted` and `invoice.payment_failed`
webhooks to update that record; do not cache an `active: true` flag that never flips back.
3. Revoke access on cancel and on failed payment (after any grace period you intend).
4. Add a test: cancel, then the status endpoint must report inactive and paid features 402/403.
Show me the subscription-status diff.
P4:/api/subscription/status
C2
Your AI endpoint answers strangers on your bill
HighVerified
We sent a single anonymous request to /api/ai/chat — no account, no API key of our own — and got a model response back. That route is calling your AI provider with your key for anyone who asks. Scripts that hunt for open proxies like this one find them in hours, and the first you will know about it is the invoice.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
$300/mo at risk— /api/ai/chat is open to anyone with no cap. One scripted abuser at 1,000 calls/day x $0.01 per call is $300/month on your model provider's bill. There is no ceiling on this number — it is what one attacker costs, not what several do.
Fix prompt — paste into Cursor / Claude Code / Lovable
My route `/api/ai/chat` calls an AI model and answers anybody — no login, no limit. Whoever finds
it gets my model provider's account for free, billed to me.
Do all of the following:
1. Require a session on the FIRST line of the handler and return 401 otherwise:
```ts
const { data: { user } } = await supabase.auth.getUser();
if (!user) return new Response('Unauthorized', { status: 401 });
```
2. Meter per user, not per IP: keep a counter (Upstash Redis / `@vercel/kv`, or a `usage`
table) keyed by user id, refuse over the plan's quota with 429, and record the usage BEFORE
calling the model so a crash mid-request cannot be used as free inference.
3. Cap the request itself: a maximum prompt length, `max_tokens` on the provider call, and a
fixed model name — never one the client can choose.
4. Set a hard spend limit and a budget alert in the model provider's dashboard today. That is
the only control that works while you are asleep.
5. Rotate the provider key if this route has been live for a while, and read the usage graph
for spikes that do not match your own traffic.
6. Add a test: call the route with no session and assert 401.
Show me the route diff.
C2:/api/ai/chat
Will break in production
R1
Your database cannot be restored
HighVerified
Your database has point-in-time recovery switched off and no stored backups at all. That means one bad migration, one `delete` without a `where`, or one confused AI edit takes data you cannot get back — not 'some' data, everything written since the last snapshot, and if there is no snapshot, the business. Nothing has to go wrong for this to be true; it is the state you are already in. (Source: /__fixture__/management/database/backups.)
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Fix prompt — paste into Cursor / Claude Code / Lovable
My Supabase project has point-in-time recovery turned OFF and no stored backups at all.
Do all of the following:
1. Turn on Point-in-Time Recovery in the Supabase dashboard (Project Settings -> Database ->
Backups). It is a paid add-on; price it against what one bad migration costs me. Without
it, the best case is losing everything since the last daily snapshot.
2. Until PITR is on, run my own nightly dump and keep it somewhere that is NOT the same
provider account:
```bash
pg_dump --format=custom --no-owner "$DATABASE_URL" > backup-$(date +%F).dump
```
Automate it (GitHub Actions on a cron works) and keep at least 7 days.
3. RESTORE one of those dumps into a scratch project and confirm the row counts. An untested
backup is not a backup.
4. Make destructive changes survivable: run migrations inside a transaction, keep every
migration in version control, and take a manual snapshot immediately before running one
against production.
5. Prefer soft deletes (`deleted_at timestamptz`) over hard deletes for user-facing data, so
the common accident is an UPDATE I can undo rather than a DELETE I cannot.
Show me the backup workflow file.
R1:backups
F3
Development debug output is live in production
MediumVerified
/api/debug answered anyone — no login — with your runtime environment (things like NODE_ENV, the server's working directory and process details). /api/crash returned a full stack trace in the response body, which shows a stranger your file paths, your framework versions and the internal name of the code that broke. None of this is a break-in on its own, but it is the map an attacker draws before one: it names your stack, your paths and your environment, and it turns guessing into looking things up.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
RESPONSEHTTP 500 (3ms)
connection: keep-alive
content-type: text/plain; charset=utf-8
date: Tue, 22 Sep 2026 06:01:16 GMT
keep-alive: timeout=5
transfer-encoding: chunked
Internal Server Error
Error: simulated unhandled crash for F3 fixture
at handle (D:\Projects\Solacess\fixtures\targets\src\server.ts:262:13)
at Server.<anonymous> (D:\Projects\Solacess\fixtures\targets\src\server.ts:211:10)
at Server.emit (node:events:508:28)
at parserOnIncoming (node:_http_server:1210:12)
at HTTPParser.parserOnHeadersComplete (node:_http_common:125:17)
Fix prompt — paste into Cursor / Claude Code / Lovable
My production app exposes development-only detail to anyone who asks:
- /api/debug returns environment/debug information
- /api/crash returns a full stack trace in the error body
Do all of the following:
1. Delete the debug routes from the production build, or gate them behind an environment
check that fails closed: `if (process.env.NODE_ENV === 'production') return new Response(null,
{ status: 404 })` as the FIRST line of the handler, not the last.
2. Add one error boundary for the whole API: catch every unhandled error, log the real error
(message + stack) to my server logs, and return only `{ error: 'Internal Server Error' }`
with a request id to the caller. Never `err.stack`, never `err.message`, in a response body.
3. In Next.js, make sure I am not returning the error object from a route handler or a server
action — serialise a safe shape instead.
4. Re-deploy, then confirm each path above returns 404 (debug routes) or a body with no file
paths, no line numbers and no framework names (error routes).
5. Treat anything the debug output revealed — env var names, internal hostnames, build ids —
as public knowledge now, and rotate anything that was a secret.
Show me the diff for the deleted routes and the error handler.
F3:debug-surface
P5
Stripe test and live keys are crossed between environments
MediumVerified
Your config at `/api/config` says the environment is "test" but ships a Stripe live-mode publishable key (`pk_live_…`). A live key in a test/staging environment means real cards can be charged from a place that is not meant to take real money.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Fix prompt — paste into Cursor / Claude Code / Lovable
Your public config at `/api/config` reports one environment but ships a Stripe key from the
other mode. Test and live Stripe keys are crossed.
Fix it:
1. Use `pk_test_`/`sk_test_` in every non-production environment and `pk_live_`/`sk_live_`
only in production, driven by environment variables per deployment.
2. Never hardcode a live key; never commit one. Keep secret keys server-side only.
3. Add a startup assertion: in a non-production environment, refuse to boot with a live key
(and vice versa).
4. Rotate any key that was shipped to the wrong environment.
Show me the config / env wiring diff.
P5:/api/config
C1
We saw no rate limit on sign-in
MediumSuspected
We sent 8 wrong-password sign-in attempts to /api/auth/login one after another. Every one was answered normally — no `429`, no `Retry-After`, no limiter header. We stop at 8 on purpose, so this does not prove there is no limiter: one set above that rate (10 a minute, 20 per 15 minutes) would not show up in a test this small. What it does mean is that nothing stopped the first 8 guesses, so please check for yourself. If there is no limit, someone with a leaked password list can try millions of combinations against your users, each one costing you a database round trip and a password hash — and the same missing guard on signup and password reset is an unmetered way to make your email provider send mail on your bill.
A strong signal, not conclusively reproduced. Treat it as a lead to check yourself.
Fix prompt — paste into Cursor / Claude Code / Lovable
My sign-in route `/api/auth/login` accepted 8 wrong-password attempts in a row with no 429
and no rate-limit header. That is not proof there is no limiter — it may just be set above
8 attempts — so start by telling me what limit is actually in place on sign-in,
signup and password reset, then do all of the following:
0. Show me where the limiter is configured. If there is none, everything below applies.
1. Put a limiter in front of every auth route — sign-in, signup, password reset, magic link,
OTP verify. Key it on BOTH the client IP and the submitted email, so one attacker cannot
spread attempts across emails and one victim cannot be locked out by an attacker's IP.
A sensible starting point: 5 attempts per email per 15 minutes, 20 per IP per 15 minutes.
2. In Next.js use middleware.ts (it runs before the route) with a shared store — Upstash
Redis or `@vercel/kv`. An in-memory counter does not work on serverless: every cold start
gets a fresh one.
3. Return `429` with a `Retry-After` header when the limit is hit, and make sure both the
success and failure paths consume the budget — counting only failures lets an attacker
confirm valid passwords for free.
4. If I am on Supabase Auth, also turn on its own rate limits (Authentication -> Rate Limits)
and cap the email-send rate, because signup/reset abuse bills my email provider directly.
5. Keep the failure response identical for 'no such user' and 'wrong password' so the
endpoint cannot be used to enumerate who has an account.
6. Add a test that fires 10 bad logins and asserts the last ones return 429.
Show me the middleware diff.
C1:/api/auth/login
F1
Security headers are missing
LowVerified
Your home page (`/`) is served without 3 security headers: content-security-policy (stops injected or third-party scripts from running and stealing session tokens); x-frame-options (stops your app being framed by another site (clickjacking)); referrer-policy (stops full urls (with ids and tokens in them) leaking to other sites in the Referer header). None of these is a hole on its own, but each one is a cheap guard rail that turns someone else's mistake — an injected script, a framed login box, a leaked url — into a non-event.
Reproduced with a real request. The proof below is what Solacess actually sent and got back.
Fix prompt — paste into Cursor / Claude Code / Lovable
My app serves responses without these security headers:
- content-security-policy
- x-frame-options
- referrer-policy
Add them globally, not page by page:
1. In Next.js, add a `headers()` entry in next.config.js matching source '/:path*' (or set
them in middleware.ts) so every route — pages, API routes and static files — gets them.
2. Use these values as the starting point:
- Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none';
frame-ancestors 'none'; base-uri 'self'
- Strict-Transport-Security: add it once the site is served over https
- X-Frame-Options: DENY
- Referrer-Policy: strict-origin-when-cross-origin
3. Roll the CSP out in Report-Only mode first if the app loads third-party scripts, read the
violation reports, then enforce.
4. Re-deploy and confirm with `curl -sI https://my-app/ | grep -i -E 'content-security|strict-transport|x-frame|referrer'`.
Show me the next.config.js diff.
F1:security-headers
Scan timeline
Scan started — deep scan of http://127.0.0.1:14611
Sep 22, 2026, 6:01 AM UTC
S1failed · 1 finding
+0.0s
S2failed · 1 finding
+0.0s
S4failed · 1 finding
+0.0s
F1failed · 1 finding
+0.0s
F2failed · 1 finding
+0.0s
F3failed · 1 finding
+0.0s
D1failed · 1 finding
+0.1s
D2failed · 1 finding
+0.1s
D3failed · 1 finding
+0.1s
D4failed · 1 finding
+0.1s
D5failed · 1 finding
+0.1s
A1failed · 1 finding
+0.1s
A2failed · 1 finding
+0.1s
A3failed · 1 finding
+0.1s
A4failed · 1 finding
+0.1s
A5failed · 1 finding
+0.1s
P1failed · 1 finding
+0.1s
P2failed · 1 finding
+0.1s
P3failed · 1 finding
+0.1s
P4failed · 1 finding
+0.1s
P5failed · 1 finding
+0.1s
C1failed · 1 finding
+0.1s
C2failed · 1 finding
+0.1s
R1failed · 1 finding
+0.1s
Scan finished — 102 requests, 24 findings
Sep 22, 2026, 6:01 AM UTC (0s)
Solacess reports only what these checks tested and proved on the day of the scan. A report is not a guarantee that your app is secure. Terms
Prefer a real scan?
The report above is a scan of our own deliberately vulnerable test app, so you can see every kind of finding. To watch Solacess run against a real, live production app instead, run a free Quick Scan of voxorio.com — nothing here is seeded; every line comes from requests made to that site just now.