← Back home
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.

Deep Scan · finished Sep 22, 2026, 6:01 AM UTC

http://127.0.0.1:14611

scan demo-democart-2026-09-22

Data leak

S1

Secret keys are shipped to the browser

CriticalVerified

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.

Affected

  • /_next/static/chunks/main-abc123.js

Proof (redacted)

REQUESTGET /_next/static/chunks/main-abc123.js  (as anon)
RESPONSEHTTP 200 (3ms)
connection: keep-alive
content-type: application/javascript; charset=utf-8
date: Tue, 22 Sep 2026 06:01:16 GMT
keep-alive: timeout=5
transfer-encoding: chunked

// Solacess fixture bundle (VULNERABLE)
window.__ENV = {
  SUPABASE_URL: "http://localhost:14611",
  SUPABASE_ANON_KEY: "[REDACTED:JWT]",
  STRIPE_PUBLISHABLE_KEY: "[REDACTED:STRIPE_KEY]",
};

// debug leftovers - should never ship (S1 / S2)
const SUPABASE_SERVICE_ROLE_KEY = "[REDACTED:JWT]"; // S2
const STRIPE_SECRET_KEY = "[REDACTED:STRIPE_KEY]"; // S1
const OPENAI_KEY = "[REDACTED:API_KEY]"; // S1
const GOOGLE_MAPS_KEY = "AIzaFIXTUREaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; // S1
const LEGACY_ADMIN_HEADER = "[REDACTED:BEARER]"; // S1

console.log('fixture app bundle loaded');
//# sourceMappingURL=main-abc123.js.map

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.

Affected

  • /_next/static/chunks/main-abc123.js
  • /rest/v1/*

Proof (redacted)

REQUESTGET /_next/static/chunks/main-abc123.js  (as anon)
RESPONSEHTTP 200 (3ms)
connection: keep-alive
content-type: application/javascript; charset=utf-8
date: Tue, 22 Sep 2026 06:01:16 GMT
keep-alive: timeout=5
transfer-encoding: chunked

// Solacess fixture bundle (VULNERABLE)
window.__ENV = {
  SUPABASE_URL: "http://localhost:14611",
  SUPABASE_ANON_KEY: "[REDACTED:JWT]",
  STRIPE_PUBLISHABLE_KEY: "[REDACTED:STRIPE_KEY]",
};

// debug leftovers - should never ship (S1 / S2)
const SUPABASE_SERVICE_ROLE_KEY = "[REDACTED:JWT]"; // S2
const STRIPE_SECRET_KEY = "[REDACTED:STRIPE_KEY]"; // S1
const OPENAI_KEY = "[REDACTED:API_KEY]"; // S1
const GOOGLE_MAPS_KEY = "AIzaFIXTUREaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; // S1
const LEGACY_ADMIN_HEADER = "[REDACTED:BEARER]"; // S1

console.log('fixture app bundle loaded');
//# sourceMappingURL=main-abc123.js.map

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.

Affected

  • feedback

Proof (redacted)

REQUESTGET http://127.0.0.1:14611/rest/v1/feedback  (as anon)
apikey: [REDACTED]
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

[{"id":"fb_1","userId":"user_a_1111","message":"Love the product"},{"id":"fb_2","userId":"user_b_2222","message":"Billing is confusing"}]
REQUESTPOST http://127.0.0.1:14611/rest/v1/feedback  (as anon, mutating)
apikey: [REDACTED]
content-type: application/json
prefer: return=representation

{"message":"solacess-scan-probe: safe to delete"}
RESPONSEHTTP 201 (3ms)
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

{"id":"fb_3","userId":"anon","message":"solacess-scan-probe: safe to delete"}

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]
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

[{"id":"note_a_1","userId":"user_a_1111","body":"Alice's private note"},{"id":"note_b_1","userId":"user_b_2222","body":"Bob's private note"}]
REQUESTGET http://127.0.0.1:14611/rest/v1/notes  (as B)
apikey: [REDACTED]
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

[{"id":"note_a_1","userId":"user_a_1111","body":"Alice's private note"},{"id":"note_b_1","userId":"user_b_2222","body":"Bob's private note"}]

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.

Affected

  • rpc/promote_to_admin

Proof (redacted)

REQUESTPOST http://127.0.0.1:14611/rest/v1/rpc/promote_to_admin  (as anon, mutating)
apikey: [REDACTED]
content-type: application/json

{"target_user_id":"68ab05e1-0031-49fd-84d1-5a6650350827"}
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

{"ok":true}

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.

Affected

  • /api/invoices/:id

Proof (redacted)

REQUESTGET /api/invoices/inv_a_1  (as A)
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

{"invoice":{"id":"inv_a_1","userId":"user_a_1111","amountCents":4900,"description":"Pro plan - Sep"}}
REQUESTGET /api/invoices/inv_a_1  (as B)
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

{"invoice":{"id":"inv_a_1","userId":"user_a_1111","amountCents":4900,"description":"Pro plan - Sep"}}

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.

Affected

  • /api/invoices

Proof (redacted)

REQUESTGET /api/invoices  (as A)
RESPONSEHTTP 200 (0ms)
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"}]}
REQUESTGET /api/invoices  (as anon)
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.

Affected

  • /api/admin/users

Proof (redacted)

REQUESTGET /api/admin/users  (as anon)
RESPONSEHTTP 401 (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

{"error":"unauthorized"}
REQUESTGET /api/admin/users  (as B)
RESPONSEHTTP 200 (0ms)
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

{"users":[{"id":"user_a_1111","email":"[REDACTED:EMAIL]","role":"user"},{"id":"user_b_2222","email":"[REDACTED:EMAIL]","role":"user"},{"id":"user_admin_3333","email":"[REDACTED:EMAIL]","role":"admin"}]}

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.

Affected

  • /api/protected/profile

Proof (redacted)

REQUESTGET /api/protected/profile  (as anon)
RESPONSEHTTP 401 (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

{"error":"unauthorized"}
REQUESTGET /api/protected/profile  (as anon)
authorization: [REDACTED]
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

{"profile":{"id":"profile_user_a_1111","userId":"user_a_1111","email":"[REDACTED:EMAIL]","role":"user","isAdmin":false,"plan":"paid","credits":100}}

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
RESPONSEHTTP 200 (2ms)
access-control-allow-credentials: true
access-control-allow-origin: https://solacess-cors-probe.example
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
vary: Origin

{"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"}]}

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.

Affected

  • /_next/static/chunks/main-abc123.js.map

Proof (redacted)

REQUESTGET /_next/static/chunks/main-abc123.js.map  (as anon)
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]
RESPONSEHTTP 200 (0ms)
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

[{"id":"profile_user_a_1111","userId":"user_a_1111","email":"[REDACTED:EMAIL]","role":"user","isAdmin":false,"plan":"paid","credits":100}]
REQUESTPATCH http://127.0.0.1:14611/rest/v1/profiles?id=eq.profile_user_a_1111  (as A, mutating)
apikey: [REDACTED]
content-type: application/json
prefer: return=representation

{"role":"user"}
RESPONSEHTTP 200 (0ms)
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

{"id":"profile_user_a_1111","userId":"user_a_1111","email":"[REDACTED:EMAIL]","role":"user","isAdmin":false,"plan":"paid","credits":100}

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.

Affected

  • /api/webhooks/stripe

Proof (redacted)

REQUESTPOST /api/webhooks/stripe  (as anon, mutating)
stripe-signature: [REDACTED]
content-type: application/json

{"id":"evt_solacess_probe_demo-democart-2026-09-22","type":"solacess.signature_probe","data":{}}
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

{"received":true}

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.

Affected

  • /api/webhooks/stripe

Proof (redacted)

REQUESTPOST /api/webhooks/stripe  (as anon, mutating)
stripe-signature: [REDACTED]
content-type: application/json

{"id":"evt_solacess_p2_demo-democart-2026-09-22","type":"checkout.session.completed","data":{"userId":"user_a_1111","credits":1}}
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

{"received":true}
REQUESTPOST /api/webhooks/stripe  (as anon, mutating)
stripe-signature: [REDACTED]
content-type: application/json

{"id":"evt_solacess_p2_demo-democart-2026-09-22","type":"checkout.session.completed","data":{"userId":"user_a_1111","credits":1}}
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

{"received":true}

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}
RESPONSEHTTP 200 (0ms)
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

{"priceId":"price_basic","quantity":1,"amountCents":1900}
REQUESTPOST /api/checkout  (as A, mutating)

{"priceId":"price_basic","quantity":1,"unitAmountCents":1}
RESPONSEHTTP 200 (0ms)
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

{"priceId":"price_basic","quantity":1,"amountCents":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.

Affected

  • /api/premium/export

Proof (redacted)

REQUESTGET /api/premium/export  (as A)
RESPONSEHTTP 200 (0ms)
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

{"content":"premium export payload"}
REQUESTGET /api/premium/export  (as B)
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

{"content":"premium export payload"}

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.

Affected

  • /api/subscription/status

Proof (redacted)

REQUESTGET /api/subscription/status  (as B)
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

{"active":true,"status":"none"}

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.

Affected

  • /api/ai/chat

Proof (redacted)

REQUESTPOST /api/ai/chat  (as anon, mutating)
content-type: application/json

{"prompt":"ping","message":"ping","messages":[{"role":"user","content":"ping"}],"max_tokens":1}
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

{"reply":"mock ai response"}

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.

Affected

  • supabase:project

Proof (redacted)

REQUESTGET /__fixture__/management/database/backups  (as anon)
RESPONSEHTTP 200 (0ms)
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

{"pitrEnabled":false,"backups":[]}

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.

Affected

  • /api/debug
  • /api/crash

Proof (redacted)

REQUESTGET /api/debug  (as anon)
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

{"nodeEnv":"development","cwd":"D:\\Projects\\Solacess","pid":26984}
REQUESTGET /api/crash  (as anon)
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.

Affected

  • /api/config

Proof (redacted)

REQUESTGET /api/config  (as anon)
RESPONSEHTTP 200 (0ms)
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

{"environment":"test","stripePublishableKey":"[REDACTED:STRIPE_KEY]"}

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.

Affected

  • /api/auth/login

Proof (redacted)

REQUESTPOST /api/auth/login  (as anon, mutating)
content-type: application/json

{"email":"[REDACTED:EMAIL]","password":"solacess-not-a-real-password"}
RESPONSEHTTP 401 (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

{"error":"invalid credentials"}
REQUESTPOST /api/auth/login  (as anon, mutating)
content-type: application/json

{"email":"[REDACTED:EMAIL]","password":"solacess-not-a-real-password"}
RESPONSEHTTP 401 (0ms)
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

{"error":"invalid credentials"}

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.

Affected

  • /

Proof (redacted)

REQUESTGET /  (as anon)
RESPONSEHTTP 200 (37ms)
connection: keep-alive
content-type: text/html; charset=utf-8
date: Tue, 22 Sep 2026 06:01:16 GMT
keep-alive: timeout=5
transfer-encoding: chunked

<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Fixture App</title></head>
<body>
<div id="root">Fixture App</div>
<script src="/_next/static/chunks/main-abc123.js"></script>
</body>
</html>

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

  1. Scan started — deep scan of http://127.0.0.1:14611

    Sep 22, 2026, 6:01 AM UTC

  2. S1 failed · 1 finding

    +0.0s

  3. S2 failed · 1 finding

    +0.0s

  4. S4 failed · 1 finding

    +0.0s

  5. F1 failed · 1 finding

    +0.0s

  6. F2 failed · 1 finding

    +0.0s

  7. F3 failed · 1 finding

    +0.0s

  8. D1 failed · 1 finding

    +0.1s

  9. D2 failed · 1 finding

    +0.1s

  10. D3 failed · 1 finding

    +0.1s

  11. D4 failed · 1 finding

    +0.1s

  12. D5 failed · 1 finding

    +0.1s

  13. A1 failed · 1 finding

    +0.1s

  14. A2 failed · 1 finding

    +0.1s

  15. A3 failed · 1 finding

    +0.1s

  16. A4 failed · 1 finding

    +0.1s

  17. A5 failed · 1 finding

    +0.1s

  18. P1 failed · 1 finding

    +0.1s

  19. P2 failed · 1 finding

    +0.1s

  20. P3 failed · 1 finding

    +0.1s

  21. P4 failed · 1 finding

    +0.1s

  22. P5 failed · 1 finding

    +0.1s

  23. C1 failed · 1 finding

    +0.1s

  24. C2 failed · 1 finding

    +0.1s

  25. R1 failed · 1 finding

    +0.1s

  26. 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.

Run a live Quick Scan of voxorio.com →