Example report · deterministic · no AI model · read-only

example/invoicing-app

ref main · scanned 2026-09-11 09:30 UTC

1 place may let one customer see another customer's data, and 1 may let a user give themselves access they should not have.

Found by reading the code, not by running it, so each one is a strong lead rather than a proven bug. 1 of them has since been reproduced in a sandbox and a fix verified.

  • 7files read
  • 2entry points
  • 2database queries
  • 3/3tables with RLS
  • 8rules

Checked 2 risks in class authorization/RLS. Verified: 0. Confirmed (no sandbox): 0. Unverified: 0.

The coverage line is the scan as it ran. Sandbox proofs made since then are shown on each finding.

What we found

Each finding starts with what it means for your customers. The technical detail for whoever fixes it is one click below.

  1. CriticalReproduced · fix verifiedAUDIT-001

    One customer can read another customer's invoices

    What this means

    This part of your app finds invoices by the ID in the request and never checks that the record belongs to the person asking. A signed-in customer who changes that ID can read someone else's invoices. It uses the admin (service-role) key, which switches off the database's own protection, Row Level Security.

    What the fix looks like

    Look the record up together with the customer's own account or team, taken from their login on the server, and refuse anything else.

    Reproduced in a sandbox. Two test customers were created in a private copy of the app. Before the fix, one of them could reach the other's data; after it, the same request was refused and each could still reach their own.

    For your developer

    AUDIT-001 Cross-tenant select on "invoices" via service-role client

    Entry
    GET /api/invoices/[id]
    Path
    HTTP request → GET /api/invoices/[id] → id eq id (user-controlled) → createServiceRoleClient (service role, bypasses RLS) → public.invoices.select
    Why
    select on public.invoices filtered by user-controlled "id" through a service-role client, with no tenant/owner scoping. The handler authenticates the caller but never checks that the row belongs to them. RLS is enabled on public.invoices with 2 policies, but the service role bypasses it.
    Where
    app/api/invoices/[id]/route.ts:6app/api/invoices/[id]/route.ts:14lib/supabase.ts:7
    Rule
    supabase.service-role-object-access-without-tenant-scope · CWE-639, CWE-284 · status likely · confidence 0.85

    verified Verified · Verified in the sandbox: the fix holds.

    Proof
    ### AUDIT-001: Cross-tenant select on "invoices" via service-role client
    
    **Severity:** critical
    **Entry:** `GET /api/invoices/[id]`
    **Expected:** A user in one tenant cannot access another tenant's data
    
    | Check | Result |
    |---|---|
    | Security test before patch | FAIL (2/3 security assertions failed: invoice authorization matrix Alice reads Bob's invoice (other tenant): DENY (AssertionError: expected [ 403, 404 ] to include 200); invoice authorization matrix Bob reads Alice's invoice (other tenant): DENY (AssertionError: expected [ 403, 404 ] to include 200)) |
    | Security test after patch | PASS (3/3 security assertions passed, 2 sanity checks passed) |
    | Existing tests after patch | SKIPPED |
    | Deterministic re-scan | PASS |
    
    **Verdict: VERIFIED FIX**
    
  2. HighLikely · not yet provenAUDIT-002

    Users can change fields they should not, such as their role or team

    What this means

    When someone saves profiles, this code writes everything the request contains straight into the database. A user can add extra fields and give themselves admin rights or move into another customer's account. In this app that includes tenant_id or role.

    What the fix looks like

    Save only the fields the form is meant to change (an allow-list), never the whole request.

    For your developer

    AUDIT-002 Mass assignment into "profiles" from the request body

    Entry
    PATCH /api/profile
    Path
    HTTP request → PATCH /api/profile → body (entire request input) → createServiceRoleClient (service_role) → public.profiles.update
    Why
    update on public.profiles writes body directly. Columns the caller could set: tenant_id, role. Pick the allowed fields explicitly.
    Where
    app/api/profile/route.ts:5app/api/profile/route.ts:11
    Rule
    supabase.mass-assignment-from-request-body · CWE-915 · status likely · confidence 0.85

    not run On your own report, “Prove it in the sandbox” queues this check against a sandboxed copy of your app.

Likely means a rule fired and nothing in the code contradicted it. Reproduced means a sandboxed copy of the app with two test customers showed the bug before the fix and not after it. Only that counts as proof.

What to do with this

Prove it and fix it

“Prove it” runs a finding against a sandboxed copy of the app with two test customers and shows 200 before, 403 after. Repositories in a known fixture layout are proven automatically; for any other repository Audit AI prepares the two-customer seed and the regression test, then runs it for you.

Ask for a verified audit

Fix it with Claude Code or Cursor

Your own coding agent reasons about each finding with the bounded context Audit AI hands it and proposes the patch; Audit AI validates it and proves it. No API key of ours involved.

claude mcp add auditai -- auditai-mcp

Check every pull request

Install the GitHub App: the same scan runs on every PR with annotations on the exact lines, and it blocks only on proven-critical issues. Works for private repositories.

Install the GitHub App

Scan your own public repository: