SAMPLE — built from an invented demo app (Lumen Notes) written by Nordik Audit for this purpose; not a customer and not a real company

App Audit report: lumen-notes

19 distinct problems (23 confirmed findings)

Summary: 19 distinct problems (23 confirmed findings, 4 of them the same root cause as another) among 26 candidate findings: 2 critical, 4 high, 5 medium, 8 low, each problem counted once; 3 rejected. Most severe: C021 "Direct dependency next pulls in 2 vulnerable packages ([email protected] itself, [email protected]); upgrade…" (critical); C003 "GET /api/export reads a notes with the service-role client by an id from the request, after only a …" (critical); C004 "A role decision reads user_metadata, which every signed-in user can edit" (high); 16 more problems are listed below.

The audit in numbers: revision, models, how each verdict was reached
  • Revision: c64304259df8fd38f3e766838c7012f0a656403c
  • Findings with a verdict: 26 of 26
  • How the verdicts were reached: AI reviewer 10; AI reviewer and second reviewer (agreed) 12; deeper check 1; OSV evidence 3
  • Verdicts: 23 confirmed, 0 need follow-up, 3 rejected
  • Distinct problems: 19 (of the 23 confirmed findings, 4 are the same root cause as another confirmed finding and are listed under it)
  • AI reviewer model: claude-fable-5-1; it checked 23 of 23 code findings against the code (package findings keep their OSV evidence)
  • Second reviewer model: claude-opus-5-5; it independently checked 13 of 13 critical or high code findings
  • Vulnerability data: OSV.dev npm snapshot of 2026-10-06, matched offline; nothing about the repository was sent. Credit: OSV.dev (CC-BY 4.0 / CC0).

Launch Risk Score

90 / 100

for https://lumen-notes-demo.pages.dev/, scanned 2026-10-07 (rubric 1.0.0; 100 means no verified finding). Passive checks only: nothing was logged into, written or submitted. How the score is built

Top verified findings:

Checked against the code by an AI reviewer; each critical or high code finding was checked again by a second AI reviewer. Each finding below says how its verdict was reached. "Confirmed" means the evidence is present at this revision. For secret-shaped text it means only that the text is there: no credential was tested against any live service. For a secret found only in git history, "present" means in a commit in the history of this revision, not in the files of the revision.

Confirmed findings

All 19 distinct problems (23 confirmed findings), most serious first; a finding with the same root cause as another sits under it. The findings below are in the report's own order. Select an ID to jump to it.
IDSeverityFindingWhere
C021CriticalDirect dependency next pulls in 2 vulnerable packages ([email protected] itself, [email protected]); upgrade target: next 15.5.24package-lock.json:621
C003CriticalGET /api/export reads a notes with the service-role client by an id from the request, after only a login checkapp/api/export/route.ts:20
C007Highsame root cause as C003 GET /api/export reads a notes by an id from the request, with no owner comparisonapp/api/export/route.ts:20
C004HighA role decision reads user_metadata, which every signed-in user can editapp/api/admin/users/route.ts:13
C005Highsame root cause as C004 GET /api/admin/users is an administrative-looking route whose only role check reads user_metadata, which every signed-in user can editapp/api/admin/users/route.ts:18
C006HighPOST /api/checkout sends unit_amount, taken from the request, to the payment providerapp/api/checkout/route.ts:36
C009HighPATCH /api/notes/[id] changes a notes with the service-role client by an id from the request, after only a login checkapp/api/notes/[id]/route.ts:34
C010Highsame root cause as C009 PATCH /api/notes/[id] updates a notes by an id from the request, with no owner comparisonapp/api/notes/[id]/route.ts:34
C013HighPolicy Signed-in users can add members on workspace_members only checks that the caller is signed insupabase/migrations/0001_init.sql:115
C001MediumRedirect to a target taken from the request, with no same-site or allow-list check in the handlerapp/auth/callback/route.ts:14
C023MediumThe note's author is taken from the request body instead of the signed-in session, so any workspace member can create notes that appear to …app/api/notes/route.ts:47
C024Mediumsame root cause as C023 The notes insert policy only checks workspace membership and never requires author_id to equal the caller's own id, so the database accepts…supabase/migrations/0001_init.sql:134
C025MediumThe workspace to upgrade is taken from the request with no check that the caller owns or belongs to it, and the Stripe webhook later marks …app/api/checkout/route.ts:30
C012MediumClient component components/WorkspaceStats.tsx imports lib/supabase/admin.ts, which builds a Supabase client from the service-role keycomponents/WorkspaceStats.tsx:4
C014MediumSupabase table audit_events is created without row-level securitysupabase/migrations/0002_billing_and_audit.sql:18
C002LowSTRIPE_SECRET_KEY falls back to a hard-coded defaultlib/stripe.ts:4
C019LowDirect dependency @supabase/ssr pulls in 2 vulnerable packages (@supabase/[email protected], [email protected]); upgrade target: @supabase/auth-js 2.70.0, cookie 0.7.0package-lock.json:256
C020LowDirect dependency @supabase/supabase-js pulls in 1 vulnerable package (@supabase/[email protected]); upgrade target: @supabase/auth-js 2.70.0package-lock.json:280
C017LowPOST /api/webhooks/stripe returns caught error text to the callerapp/api/webhooks/stripe/route.ts:16
C018LowNo security headers are configured in next.confignext.config.mjs:2
C026LowThe note update policy only checks that the caller is the author and has no separate with check on workspace_id, so an author can move thei…supabase/migrations/0001_init.sql:138
C015LowPOST /api/checkout builds a URL from the origin header (2 places)app/api/checkout/route.ts:42
C016LowPolicy Users can update their own profile lets users update their own profiles row, including plansupabase/migrations/0001_init.sql:93

A finding that has the same root cause as another is listed directly under that root finding, with a smaller heading, and is counted once.

Critical C021 Direct dependency next pulls in 2 vulnerable packages ([email protected] itself, [email protected]); upgrade target: next 15.5.24
  • Location: package-lock.json:621
Evidence and how this verdict was reached
  • Status: Confirmed (OSV evidence): the evidence is present at this revision.
  • Evidence: next: [email protected] itself, [email protected], line SHA-256 0aa341d4f0523a3cf6dc9ce8ac901d7a59d3aa35589977cdb3910fe7b2e79dfb
  • Verdict reached by: OSV evidence
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open package-lock.json at line 621.
  3. Compare with the masked excerpt next: [email protected] itself, [email protected] (the line's SHA-256 is 0aa341d4f0523a3cf6dc9ce8ac901d7a59d3aa35589977cdb3910fe7b2e79dfb).

Limitations: The versions locked in the lockfile (package-lock.json, yarn.lock or pnpm-lock.yaml) were matched against a downloaded OSV.dev advisory snapshot, offline, and grouped under the direct dependency whose lockfile dependency graph pulls them in; whether the app uses the vulnerable code path was not checked.

Critical C003 GET /api/export reads a notes with the service-role client by an id from the request, after only a login check
  • Location: app/api/export/route.ts:20
  • The same root cause is also reported at C007.
  • AI reviewer: The export route checks only that someone is signed in, then uses the service-role client (which bypasses row policies) to return every note of whatever workspace id the caller puts in the query string. (relied on app/api/export/route.ts:6-22)
  • Second reviewer: Any signed-in user can call GET /api/export?workspaceId=<another workspace's id> and download all of that workspace's notes, because the route only checks for a login and then queries with the service-role client, which skips the membership rules. (relied on app/api/export/route.ts:6-24)
Evidence and how this verdict was reached
  • Check families: service-role, object-scoping (the first is the main one)
  • Status: Confirmed (AI reviewer and second reviewer (agreed)): the evidence is present at this revision.
  • Evidence: .eq('workspace_id', workspaceId), line SHA-256 a971e190691c2bb7f9b6f4d9e497c1e392fbbc48b6aa8e0227258e01178b2dd6
  • Verdict reached by: AI reviewer and second reviewer (agreed)
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open app/api/export/route.ts at line 20.
  3. Compare with the masked excerpt .eq('workspace_id', workspaceId) (the line's SHA-256 is a971e190691c2bb7f9b6f4d9e497c1e392fbbc48b6aa8e0227258e01178b2dd6).

Limitations: Read from the source: identity checks, helpers and middleware are followed through imports of tracked files only. A check inside a package, a custom check that is not recognised, or a protection added by the host or a proxy is not seen. No request was made to the app.

High C007 same root cause as C003 GET /api/export reads a notes by an id from the request, with no owner comparison
  • Location: app/api/export/route.ts:20
  • Same root cause as C003 (listed above, which is confirmed too): the AI reviewers judged this the same problem seen at another place, so it is counted once.
  • AI reviewer: The export query filters only by the caller-supplied workspace id with a client that ignores row policies, so no ownership or membership check exists; same defect as the service-role finding on this route. (relied on app/api/export/route.ts:13-21)
  • Second reviewer: This is the same missing workspace-membership check on the export route that C003 reports: the service-role query returns any workspace's notes to any signed-in caller. (relied on app/api/export/route.ts:11-21)
Evidence and how this verdict was reached
  • Check families: service-role, object-scoping, request-trust (the first is the main one)
  • Status: Confirmed (AI reviewer and second reviewer (agreed)): the evidence is present at this revision.
  • Evidence: .eq('workspace_id', workspaceId), line SHA-256 a971e190691c2bb7f9b6f4d9e497c1e392fbbc48b6aa8e0227258e01178b2dd6
  • Verdict reached by: AI reviewer and second reviewer (agreed)
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open app/api/export/route.ts at line 20.
  3. Compare with the masked excerpt .eq('workspace_id', workspaceId) (the line's SHA-256 is a971e190691c2bb7f9b6f4d9e497c1e392fbbc48b6aa8e0227258e01178b2dd6).

Limitations: Read from the source: identity checks, helpers and middleware are followed through imports of tracked files only. A check inside a package, a custom check that is not recognised, or a protection added by the host or a proxy is not seen. No request was made to the app.

High C004 A role decision reads user_metadata, which every signed-in user can edit
  • Location: app/api/admin/users/route.ts:13
  • The same root cause is also reported at C005.
  • AI reviewer: The only admin check reads is_admin from user_metadata, which any signed-in Supabase user can set on their own account, and nothing else restricts the route. (relied on app/api/admin/users/route.ts:11-18)
  • Second reviewer: Any signed-in user can set is_admin=true in their own user_metadata using the normal Supabase updateUser call, then call GET /api/admin/users and get the email addresses and sign-in dates of other users. (relied on app/api/admin/users/route.ts:7-28)
Evidence and how this verdict was reached
  • Check families: route-authorization, request-trust (the first is the main one)
  • Status: Confirmed (AI reviewer and second reviewer (agreed)): the evidence is present at this revision.
  • Evidence: if (user.user_metadata?.is_admin !== true) {, line SHA-256 bc21dfe22811ba9a3cb290fd3331790ce08f82bbe54d8454e1e1c80c648d596d
  • Verdict reached by: AI reviewer and second reviewer (agreed)
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open app/api/admin/users/route.ts at line 13.
  3. Compare with the masked excerpt if (user.user_metadata?.is_admin !== true) { (the line's SHA-256 is bc21dfe22811ba9a3cb290fd3331790ce08f82bbe54d8454e1e1c80c648d596d).

Limitations: Read from the source: identity checks, helpers and middleware are followed through imports of tracked files only. A check inside a package, a custom check that is not recognised, or a protection added by the host or a proxy is not seen. No request was made to the app.

High C005 same root cause as C004 GET /api/admin/users is an administrative-looking route whose only role check reads user_metadata, which every signed-in user can edit
  • Location: app/api/admin/users/route.ts:18
  • Same root cause as C004 (listed above, which is confirmed too): the AI reviewers judged this the same problem seen at another place, so it is counted once.
  • AI reviewer: Because the admin flag comes from user-editable metadata, any signed-in user can pass the check and have the service-role client list all accounts with their emails; same root cause as the metadata finding. (relied on app/api/admin/users/route.ts:13-27)
  • Second reviewer: This is the same user_metadata admin check reported in C004: a user who makes themselves 'admin' gets the service-role user list with other people's emails, and the middleware does not cover /api paths. (relied on app/api/admin/users/route.ts:11-28)
Evidence and how this verdict was reached
  • Check families: route-authorization, request-trust, service-role (the first is the main one)
  • Status: Confirmed (AI reviewer and second reviewer (agreed)): the evidence is present at this revision.
  • Evidence: const { data, error } = await admin.auth.admin.listUsers({ page: 1, perPage: 100 });, line SHA-256 9fea555a689b565b262e55e0ded890ed8444a18f9247b8ddb9fda6c9b0883c3f
  • Verdict reached by: AI reviewer and second reviewer (agreed)
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open app/api/admin/users/route.ts at line 18.
  3. Compare with the masked excerpt const { data, error } = await admin.auth.admin.listUsers({ page: 1, perPage: 100 }); (the line's SHA-256 is 9fea555a689b565b262e55e0ded890ed8444a18f9247b8ddb9fda6c9b0883c3f).

Limitations: Read from the source: identity checks, helpers and middleware are followed through imports of tracked files only. A check inside a package, a custom check that is not recognised, or a protection added by the host or a proxy is not seen. No request was made to the app.

High C006 POST /api/checkout sends unit_amount, taken from the request, to the payment provider
  • Location: app/api/checkout/route.ts:36
  • AI reviewer: The per-seat price is taken straight from the request (any integer down to zero) and sent to Stripe, and the webhook then activates the team plan regardless of what was paid. (relied on app/api/checkout/route.ts:6-40)
  • Second reviewer: Any signed-in user can POST to /api/checkout with unitAmount set to 0 (the schema only requires 0 or more), and the server passes that price to Stripe as sent, so they can buy the Team subscription for nothing. (relied on app/api/checkout/route.ts:6-38)
Evidence and how this verdict was reached
  • Status: Confirmed (AI reviewer and second reviewer (agreed)): the evidence is present at this revision.
  • Evidence: unit_amount: input.unitAmount,, line SHA-256 ef01ada2f74f809631226e4c6ffd80af0f0c9365b7b5b003140c3bc712991a44
  • Verdict reached by: AI reviewer and second reviewer (agreed)
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open app/api/checkout/route.ts at line 36.
  3. Compare with the masked excerpt unit_amount: input.unitAmount, (the line's SHA-256 is ef01ada2f74f809631226e4c6ffd80af0f0c9365b7b5b003140c3bc712991a44).

Limitations: A heuristic reading of one handler: it cannot tell whether the value is recomputed or checked elsewhere, so this is never confirmed on its own. No request was made to the app.

High C009 PATCH /api/notes/[id] changes a notes with the service-role client by an id from the request, after only a login check
  • Location: app/api/notes/[id]/route.ts:34
  • The same root cause is also reported at C010.
  • AI reviewer: PATCH checks only that someone is signed in, then updates whatever note id is in the URL with the service-role client, with no check that the caller is a member of that note's workspace. (relied on app/api/notes/[id]/route.ts:18-39)
  • Second reviewer: Any signed-in user can send PATCH /api/notes/<any note id> with a new title or body and overwrite a note in someone else's workspace, because the service-role update only filters by the id and never checks membership or authorship. (relied on app/api/notes/[id]/route.ts:20-39)
Evidence and how this verdict was reached
  • Check families: service-role, object-scoping (the first is the main one)
  • Status: Confirmed (AI reviewer and second reviewer (agreed)): the evidence is present at this revision.
  • Evidence: .eq('id', params.id), line SHA-256 a4aa32484f405fba4f2f88b08509b226991faee7b680a8b8b6c41e7b2f4d1ebb
  • Verdict reached by: AI reviewer and second reviewer (agreed)
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open app/api/notes/[id]/route.ts at line 34.
  3. Compare with the masked excerpt .eq('id', params.id) (the line's SHA-256 is a4aa32484f405fba4f2f88b08509b226991faee7b680a8b8b6c41e7b2f4d1ebb).

Limitations: Read from the source: identity checks, helpers and middleware are followed through imports of tracked files only. A check inside a package, a custom check that is not recognised, or a protection added by the host or a proxy is not seen. No request was made to the app.

High C010 same root cause as C009 PATCH /api/notes/[id] updates a notes by an id from the request, with no owner comparison
  • Location: app/api/notes/[id]/route.ts:34
  • Same root cause as C009 (listed above, which is confirmed too): the AI reviewers judged this the same problem seen at another place, so it is counted once.
  • AI reviewer: The update is keyed only on the note id from the URL with a client that bypasses row policies and no owner or membership comparison; same defect as the service-role finding on this handler. (relied on app/api/notes/[id]/route.ts:30-36)
  • Second reviewer: This is the same unscoped service-role update on PATCH /api/notes/[id] reported in C009. (relied on app/api/notes/[id]/route.ts:25-36)
Evidence and how this verdict was reached
  • Check families: service-role, object-scoping (the first is the main one)
  • Status: Confirmed (AI reviewer and second reviewer (agreed)): the evidence is present at this revision.
  • Evidence: .eq('id', params.id), line SHA-256 a4aa32484f405fba4f2f88b08509b226991faee7b680a8b8b6c41e7b2f4d1ebb
  • Verdict reached by: AI reviewer and second reviewer (agreed)
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open app/api/notes/[id]/route.ts at line 34.
  3. Compare with the masked excerpt .eq('id', params.id) (the line's SHA-256 is a4aa32484f405fba4f2f88b08509b226991faee7b680a8b8b6c41e7b2f4d1ebb).

Limitations: Read from the source: identity checks, helpers and middleware are followed through imports of tracked files only. A check inside a package, a custom check that is not recognised, or a protection added by the host or a proxy is not seen. No request was made to the app.

High C013 Policy Signed-in users can add members on workspace_members only checks that the caller is signed in
  • Location: supabase/migrations/0001_init.sql:115
  • AI reviewer: Row security is on and the insert policy only requires being signed in, so any user can add themselves to any workspace and then read, create and export that team's notes. (relied on supabase/migrations/0001_init.sql:84-117)
  • Second reviewer: Row-level security is on, but the insert policy only checks that the caller is signed in, so any signed-in user can use the Supabase API to insert a row with someone else's workspace_id and their own user_id, and then pass is_workspace_member and read that workspace's notes and members. (relied on supabase/migrations/0001_init.sql:110-126)
Evidence and how this verdict was reached
  • Status: Confirmed (AI reviewer and second reviewer (agreed)): the evidence is present at this revision.
  • Evidence: create policy "Signed-in users can add members", line SHA-256 818a696a1ac5004245d2737298ec967987976cffaf63a24dc6883d8c2248ad43
  • Verdict reached by: AI reviewer and second reviewer (agreed)
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open supabase/migrations/0001_init.sql at line 115.
  3. Compare with the masked excerpt create policy "Signed-in users can add members" (the line's SHA-256 is 818a696a1ac5004245d2737298ec967987976cffaf63a24dc6883d8c2248ad43).

Limitations: Read from the SQL files committed under supabase/ only; row-level security or policies changed in the Supabase dashboard, and SQL that is not committed, are not seen. No request was made to the database.

Medium C001 Redirect to a target taken from the request, with no same-site or allow-list check in the handler
  • Location: app/auth/callback/route.ts:14
  • AI reviewer: The next parameter is passed to new URL with the site as base, so an absolute URL like another site's address wins and the user is sent off-site after signing in. (relied on app/auth/callback/route.ts:6-15)
Evidence and how this verdict was reached
  • Status: Confirmed (AI reviewer): the evidence is present at this revision.
  • Evidence: return NextResponse.redirect(new URL(next, origin));, line SHA-256 9e2f9bec765ab719f81d86324c5fbdeb5b6d6c17b3a7f3faf9a4376f995e8adb
  • Verdict reached by: AI reviewer
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open app/auth/callback/route.ts at line 14.
  3. Compare with the masked excerpt return NextResponse.redirect(new URL(next, origin)); (the line's SHA-256 is 9e2f9bec765ab719f81d86324c5fbdeb5b6d6c17b3a7f3faf9a4376f995e8adb).

Limitations: Static pattern within one file; input from other files, framework defaults and runtime behaviour were not traced or observed.

Medium C023 The note's author is taken from the request body instead of the signed-in session, so any workspace member can create notes that appear to …
  • Location: app/api/notes/route.ts:47
  • The same root cause is also reported at C024.
  • AI reviewer: The note's author id is taken from the request body and the insert policy only checks workspace membership, so any member can create a note that looks like it was written by someone else. (relied on app/api/notes/route.ts:42-50)
Evidence and how this verdict was reached
  • Found by AI: proposed by an AI reading the code, not by a rule-based check, and then confirmed.
  • Check families: request-trust, object-scoping (the first is the main one)
  • Status: Confirmed (AI reviewer): the evidence is present at this revision.
  • Evidence: author_id: input.authorId ?? user.id,, line SHA-256 ca193bcd03ea9c07d1b2f8dda57c586117fddfc391f0da0a54895f270ec4fa7f
  • Verdict reached by: AI reviewer
How to check it yourself, and its limits
  1. Open app/api/notes/route.ts at this revision and go to line 47.
  2. Read the quoted line and the code around it. The problem to look for: The note's author is taken from the request body instead of the signed-in session, so any workspace member can create notes that appear to be written by another user and that user then gains edit and delete rights over them under the row policies.
  3. Follow the code that reaches this line (the route, the caller or the setting that uses it) and note every check on the way: login, validation, escaping, permission.
  4. Try an input or request that reaches this line, in a local copy of the app or by tracing it by hand. The problem is shown if it gets through unchecked and the result is the one described above; if a check on the way stops it, the finding does not apply.

Limitations: Found by AI: an AI read a bounded set of files and proposed this, and it did not follow other files, so code it did not read could change the answer. The finding's state or verdict says how far it was checked.

Medium C024 same root cause as C023 The notes insert policy only checks workspace membership and never requires author_id to equal the caller's own id, so the database accepts…
  • Location: supabase/migrations/0001_init.sql:134
  • Same root cause as C023 (listed above, which is confirmed too): the AI reviewers judged this the same problem seen at another place, so it is counted once.
  • AI reviewer: The notes insert policy only requires workspace membership and never ties author_id to the caller, so the database accepts notes attributed to any user; this is the same missing check as the route finding. (relied on supabase/migrations/0001_init.sql:132-134)
Evidence and how this verdict was reached
  • Found by AI: proposed by an AI reading the code, not by a rule-based check, and then confirmed.
  • Check families: request-trust, object-scoping, database-policy (the first is the main one)
  • Status: Confirmed (AI reviewer): the evidence is present at this revision.
  • Evidence: with check (public.is_workspace_member(workspace_id));, line SHA-256 2c3c9351b147e60e152a40ef72c4395b585313f5c2d9efdcec79e8c6ca40d476
  • Verdict reached by: AI reviewer
How to check it yourself, and its limits
  1. Open supabase/migrations/0001_init.sql at this revision and go to line 134.
  2. Read the quoted line and the code around it. The problem to look for: The notes insert policy only checks workspace membership and never requires author_id to equal the caller's own id, so the database accepts notes attributed to any user, which is what lets the API above spoof authorship.
  3. Follow the code that reaches this line (the route, the caller or the setting that uses it) and note every check on the way: login, validation, escaping, permission.
  4. Try an input or request that reaches this line, in a local copy of the app or by tracing it by hand. The problem is shown if it gets through unchecked and the result is the one described above; if a check on the way stops it, the finding does not apply.

Limitations: Found by AI: an AI read a bounded set of files and proposed this, and it did not follow other files, so code it did not read could change the answer. The finding's state or verdict says how far it was checked.

Medium C025 The workspace to upgrade is taken from the request with no check that the caller owns or belongs to it, and the Stripe webhook later marks …
  • Location: app/api/checkout/route.ts:30
  • AI reviewer: The checkout route accepts any workspace id without checking the caller belongs to it, and the webhook then marks that workspace's subscription active, so a user can upgrade (or cheaply attach a subscription to) any workspace they name. (relied on app/api/checkout/route.ts:21-44)
Evidence and how this verdict was reached
  • Found by AI: proposed by an AI reading the code, not by a rule-based check, and then confirmed.
  • Check families: request-trust, object-scoping (the first is the main one)
  • Status: Confirmed (AI reviewer): the evidence is present at this revision.
  • Evidence: client_reference_id: input.workspaceId,, line SHA-256 26b000b1e8efe126b8034abbe9d608edc513c8150c200b02dc60f65a5246683c
  • Verdict reached by: AI reviewer
How to check it yourself, and its limits
  1. Open app/api/checkout/route.ts at this revision and go to line 30.
  2. Read the quoted line and the code around it. The problem to look for: The workspace to upgrade is taken from the request with no check that the caller owns or belongs to it, and the Stripe webhook later marks that workspace as an active team plan, so a user can attach a paid (or, given the client-set price, free) subscription to any workspace they name.
  3. Follow the code that reaches this line (the route, the caller or the setting that uses it) and note every check on the way: login, validation, escaping, permission.
  4. Try an input or request that reaches this line, in a local copy of the app or by tracing it by hand. The problem is shown if it gets through unchecked and the result is the one described above; if a check on the way stops it, the finding does not apply.

Limitations: Found by AI: an AI read a bounded set of files and proposed this, and it did not follow other files, so code it did not read could change the answer. The finding's state or verdict says how far it was checked.

Medium C012 Client component components/WorkspaceStats.tsx imports lib/supabase/admin.ts, which builds a Supabase client from the service-role key
  • Location: components/WorkspaceStats.tsx:4
  • Severity: Medium (lowered from High by the AI reviewer, who said: The service-role key is not currently exposed since the env variable is not public, so this is a dangerous pattern rather than an active leak.)
  • AI reviewer: A browser component actually calls the service-role client at runtime; the key is not in the bundle today because the variable is server-only, but the component is broken and the obvious fix would ship the key to every visitor. (relied on components/WorkspaceStats.tsx:1-16)
  • Second reviewer: A 'use client' component calls the service-role client builder, so that code ships to the browser, where it cannot work without the key, and the obvious 'fix' of renaming the variable to NEXT_PUBLIC_ would publish the key. (relied on components/WorkspaceStats.tsx:1-16)
Evidence and how this verdict was reached
  • Check families: service-role, secrets (the first is the main one)
  • Status: Confirmed (AI reviewer and second reviewer (agreed)): the evidence is present at this revision.
  • Evidence: import { createAdminClient } from '@/lib/supabase/admin';, line SHA-256 402d79961e8b50125ee89e0474afaa7598579dbf2a9e8b492dcc2dbacb7e67af
  • Verdict reached by: AI reviewer and second reviewer (agreed)
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open components/WorkspaceStats.tsx at line 4.
  3. Compare with the masked excerpt import { createAdminClient } from '@/lib/supabase/admin'; (the line's SHA-256 is 402d79961e8b50125ee89e0474afaa7598579dbf2a9e8b492dcc2dbacb7e67af).

Limitations: Read from the source: identity checks, helpers and middleware are followed through imports of tracked files only. A check inside a package, a custom check that is not recognised, or a protection added by the host or a proxy is not seen. No request was made to the app.

Medium C014 Supabase table audit_events is created without row-level security
  • Location: supabase/migrations/0002_billing_and_audit.sql:18
  • Severity: Medium (lowered from High by the AI reviewer, who said: Nothing in the repository inserts audit rows, so the table is empty and the exposure is of a currently unused table.)
  • AI reviewer: The audit_events table is created without row-level security, so it is reachable through the Supabase API with the public key, though no app code writes to it yet so it holds no data at this revision. (relied on supabase/migrations/0002_billing_and_audit.sql:18-27)
  • Second reviewer: audit_events is created in the public schema and row-level security is never turned on for it, so with Supabase's default table grants anyone holding the public anon key can read, add or delete activity records for every workspace through the REST API. (relied on supabase/migrations/0002_billing_and_audit.sql:12-27)
Evidence and how this verdict was reached
  • Status: Confirmed (AI reviewer and second reviewer (agreed)): the evidence is present at this revision.
  • Evidence: create table public.audit_events (, line SHA-256 4da251e557d0a4d7bcd638854b26ab4a79541fd2019e76596c5f58cc232899f7
  • Verdict reached by: AI reviewer and second reviewer (agreed)
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open supabase/migrations/0002_billing_and_audit.sql at line 18.
  3. Compare with the masked excerpt create table public.audit_events ( (the line's SHA-256 is 4da251e557d0a4d7bcd638854b26ab4a79541fd2019e76596c5f58cc232899f7).

Limitations: Read from the SQL files committed under supabase/ only; row-level security or policies changed in the Supabase dashboard, and SQL that is not committed, are not seen. No request was made to the database.

Low C002 STRIPE_SECRET_KEY falls back to a hard-coded default
  • Location: lib/stripe.ts:4
  • Severity: Low (lowered from Medium by the AI reviewer, who said: The fallback literal is an obvious dummy value, so the only risk is a silent misconfiguration rather than a leaked credential.)
Evidence and how this verdict was reached
  • Status: Confirmed (AI reviewer): the text is present at this revision. It was not tested against any live service.
  • Evidence: STRIPE_SECRET_KEY || sk_tes…(44 chars), line SHA-256 ccc42474e7b11501cbbcd828eed57b5acb5573cc489ea9ac2b5aa440a048edaf
  • Verdict reached by: AI reviewer
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open lib/stripe.ts at line 4.
  3. Compare with the masked excerpt STRIPE_SECRET_KEY || sk_tes…(44 chars) (the line's SHA-256 is ccc42474e7b11501cbbcd828eed57b5acb5573cc489ea9ac2b5aa440a048edaf).

Limitations: Only the fallback literal was seen at revision c64304259df8fd38f3e766838c7012f0a656403c; whether the variable is set where the app is deployed is unknown. No credential was tested against any live service.

Low C019 Direct dependency @supabase/ssr pulls in 2 vulnerable packages (@supabase/[email protected], [email protected]); upgrade target: @supabase/auth-js 2.70.0, cookie 0.7.0
  • Location: package-lock.json:256
Evidence and how this verdict was reached
  • Status: Confirmed (OSV evidence): the evidence is present at this revision.
  • Evidence: @supabase/ssr: @supabase/[email protected], [email protected], line SHA-256 19d962b9a1efd686ca6c05c909def944515b01496b3089cb59a5cada3ce20aa9
  • Verdict reached by: OSV evidence
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open package-lock.json at line 256.
  3. Compare with the masked excerpt @supabase/ssr: @supabase/[email protected], [email protected] (the line's SHA-256 is 19d962b9a1efd686ca6c05c909def944515b01496b3089cb59a5cada3ce20aa9).

Limitations: The versions locked in the lockfile (package-lock.json, yarn.lock or pnpm-lock.yaml) were matched against a downloaded OSV.dev advisory snapshot, offline, and grouped under the direct dependency whose lockfile dependency graph pulls them in; whether the app uses the vulnerable code path was not checked.

Low C020 Direct dependency @supabase/supabase-js pulls in 1 vulnerable package (@supabase/[email protected]); upgrade target: @supabase/auth-js 2.70.0
  • Location: package-lock.json:280
Evidence and how this verdict was reached
  • Status: Confirmed (OSV evidence): the evidence is present at this revision.
  • Evidence: @supabase/supabase-js: @supabase/[email protected], line SHA-256 6600ff8b88168ee643ba6174f30a4310cbc43f504fc560cc06ac7ffdc28b06df
  • Verdict reached by: OSV evidence
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open package-lock.json at line 280.
  3. Compare with the masked excerpt @supabase/supabase-js: @supabase/[email protected] (the line's SHA-256 is 6600ff8b88168ee643ba6174f30a4310cbc43f504fc560cc06ac7ffdc28b06df).

Limitations: The versions locked in the lockfile (package-lock.json, yarn.lock or pnpm-lock.yaml) were matched against a downloaded OSV.dev advisory snapshot, offline, and grouped under the direct dependency whose lockfile dependency graph pulls them in; whether the app uses the vulnerable code path was not checked.

Low C017 POST /api/webhooks/stripe returns caught error text to the caller
  • Location: app/api/webhooks/stripe/route.ts:16
  • AI reviewer: The webhook returns the raw error message from signature verification to the caller, which is a small information leak but reveals only Stripe library wording. (relied on app/api/webhooks/stripe/route.ts:13-17)
Evidence and how this verdict was reached
  • Status: Confirmed (AI reviewer): the evidence is present at this revision.
  • Evidence: return NextResponse.json({ error: 'Webhook error: ${(err as Error).message}' }, { status: 400 });, line SHA-256 f43853c2c55a061baf3e107e1c07e7df2d20fa61b6bb87d1a5a17b7d77394928
  • Verdict reached by: AI reviewer
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open app/api/webhooks/stripe/route.ts at line 16.
  3. Compare with the masked excerpt return NextResponse.json({ error: Webhook error: ${(err as Error).message} }, { status: 400 }); (the line's SHA-256 is f43853c2c55a061baf3e107e1c07e7df2d20fa61b6bb87d1a5a17b7d77394928).

Limitations: Static reading of source and configuration; headers added by the host, a CDN or a proxy, and the error text the running app actually returns, were not observed.

Low C018 No security headers are configured in next.config
  • Location: next.config.mjs:2
  • AI reviewer: The Next.js config sets only reactStrictMode and the middleware adds no headers, so no security headers are set by the app itself. (relied on next.config.mjs:1-6)
Evidence and how this verdict was reached
  • Status: Confirmed (AI reviewer): the evidence is present at this revision.
  • Evidence: const nextConfig = {, line SHA-256 db22c69b551ea2392d0ecc406d402ee3b797fa1a754c351eeea8c41f3a1fc7f3
  • Verdict reached by: AI reviewer
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open next.config.mjs at line 2.
  3. Compare with the masked excerpt const nextConfig = { (the line's SHA-256 is db22c69b551ea2392d0ecc406d402ee3b797fa1a754c351eeea8c41f3a1fc7f3).

Limitations: Static reading of source and configuration; headers added by the host, a CDN or a proxy, and the error text the running app actually returns, were not observed.

Low C026 The note update policy only checks that the caller is the author and has no separate with check on workspace_id, so an author can move thei…
  • Location: supabase/migrations/0001_init.sql:138
  • AI reviewer: The update policy only requires the caller to be the author and has no separate with-check on workspace_id, so an author can move their own note into a workspace they are not a member of (and also flip is_public). (relied on supabase/migrations/0001_init.sql:136-138)
Evidence and how this verdict was reached
  • Found by AI: proposed by an AI reading the code, not by a rule-based check, and then confirmed.
  • Status: Confirmed (AI reviewer): the evidence is present at this revision.
  • Evidence: using (author_id = auth.uid());, line SHA-256 e68785eb03488eba099c58256deec8381e671249b1ed79a95a6e7810388b1947
  • Verdict reached by: AI reviewer
How to check it yourself, and its limits
  1. Open supabase/migrations/0001_init.sql at this revision and go to line 138.
  2. Read the quoted line and the code around it. The problem to look for: The note update policy only checks that the caller is the author and has no separate with check on workspace_id, so an author can move their note into any other workspace by id without being a member of it, making it appear in that team's note list.
  3. Follow the code that reaches this line (the route, the caller or the setting that uses it) and note every check on the way: login, validation, escaping, permission.
  4. Try an input or request that reaches this line, in a local copy of the app or by tracing it by hand. The problem is shown if it gets through unchecked and the result is the one described above; if a check on the way stops it, the finding does not apply.

Limitations: Found by AI: an AI read a bounded set of files and proposed this, and it did not follow other files, so code it did not read could change the answer. The finding's state or verdict says how far it was checked.

Low C015 POST /api/checkout builds a URL from the origin header (2 places)
  • Location: app/api/checkout/route.ts:42
  • Severity: Low (lowered from Medium by the AI reviewer, who said: Only the signed-in caller's own checkout session is affected and nothing in the code reads profiles.plan, so the abuse scenarios are limited.)
  • AI reviewer: The success and cancel URLs are built from the request Origin header and only fall back to the configured site URL when the header is missing, so a caller can point the post-payment redirect at another site. (relied on app/api/checkout/route.ts:25-43)
Evidence and how this verdict was reached
  • Status: Confirmed (AI reviewer): the evidence is present at this revision.
  • Evidence: success_url: '${origin}/workspace/${input.workspaceId}?upgraded=1',, line SHA-256 78a4a97a8aea3b28189cb13c037ac89a55dc756ecce84b92737e507994c8c71b
  • Verdict reached by: AI reviewer
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open app/api/checkout/route.ts at line 42.
  3. Compare with the masked excerpt success_url: ${origin}/workspace/${input.workspaceId}?upgraded=1, (the line's SHA-256 is 78a4a97a8aea3b28189cb13c037ac89a55dc756ecce84b92737e507994c8c71b).
  4. The same finding is also at app/api/checkout/route.ts:43.

Limitations: A heuristic reading of one handler: it cannot tell whether the value is recomputed or checked elsewhere, so this is never confirmed on its own. No request was made to the app.

Low C016 Policy Users can update their own profile lets users update their own profiles row, including plan
  • Location: supabase/migrations/0001_init.sql:93
  • Severity: Low (lowered from Medium by the AI reviewer, who said: No application code consumes profiles.plan, so the editable column currently has no effect on access or billing.)
  • AI reviewer: The update policy lets a user change any column of their own profile including plan, but no code in the repository reads profiles.plan (billing uses the subscriptions table), so changing it grants nothing today. (relied on supabase/migrations/0001_init.sql:3-8)
Evidence and how this verdict was reached
  • Status: Confirmed (AI reviewer): the evidence is present at this revision.
  • Evidence: create policy "Users can update their own profile", line SHA-256 a39fce35e9d32f5a89ec5a75ca6b14e6960dcccd40e3adfd27220f508811bbde
  • Verdict reached by: AI reviewer
How to check it yourself, and its limits
  1. In a clone of the repository, run git checkout --detach c64304259df8fd38f3e766838c7012f0a656403c.
  2. Open supabase/migrations/0001_init.sql at line 93.
  3. Compare with the masked excerpt create policy "Users can update their own profile" (the line's SHA-256 is a39fce35e9d32f5a89ec5a75ca6b14e6960dcccd40e3adfd27220f508811bbde).

Limitations: Read from the SQL files committed under supabase/ only; row-level security or policies changed in the Supabase dashboard, and SQL that is not committed, are not seen. No request was made to the database.

Needs follow-up

None.

Rejected findings

0 findings were reviewed and rejected by the owner; they are not listed.

Limitations and exclusions

About this report

AI-found issues are quote-checked against the code but may still be wrong. A person released this report. An empty section means nothing was found by these checks, not that there is no risk.

Not a penetration test, a certification, a compliance statement or proof that your app is safe. We do not run your code; please run your tests before merging.

"The owner" in this report means the person who runs this service and released the report, not the owner of your app.

Findings the AI called not real

3 findings the AI called not real

3 findings were rejected on the AI's answer without the owner reading each one; each is listed, with how the verdict was reached, so it can be checked:

  • C022: Secret-shaped text (Supabase service_role key format) remains in git history of .env, at .env. Rejected (AI reviewer and second reviewer (agreed)): Test or example code. AI reviewer: The scanner marks the value as a self-declared placeholder and the surrounding file points at a made-up example domain, so this looks like a demo value rather than a real service-role key. (relied on .env:1-5 as in old commit 68ee4255c1)
  • C008: GET /api/health: looks public by design, at app/api/health/route.ts:9. Rejected (AI reviewer and second reviewer (agreed)): False positive. AI reviewer: The health route is an uptime check that returns only an ok flag and status code, never any row data, so being public is by design and harmless. (relied on app/api/health/route.ts:6-11) Second reviewer: The health route uses a fixed query that nobody can change from the request and only returns ok or down, so it gives away no data and changes nothing. (relied on app/api/health/route.ts:7-11)
  • C011: CORS answers with whatever Origin the request sends, and allows credentials, at app/api/public/notes/[id]/route.ts:17. Rejected (deeper check): False positive. Deeper check: The route reflects any website's origin and allows cookies to be sent, but it only ever returns notes whose author marked them public, and the database policy lets anyone read those notes without signing in, so a malicious site gains nothing it could not fetch anonymously. (relied on app/api/public/notes/[id]/route.ts:8-20)

Want a report like this for your app?

Email is the only way to reach us. Nothing is charged until we have confirmed in writing that your repository fits.