The Guard That Ran Backwards (Diagram)

The reason this bug survived to production is that nothing about it looks broken. There is no missing check, no TODO, no unhandled path. The guard exists, it runs on every request, and it returns an answer. The answer is just the opposite of the right one.

A researcher found 16 vulnerabilities in a Lovable-hosted exam app, six of them critical. The one that exposed 18,697 user records was this: the AI implemented access control through Supabase remote procedure calls and inverted the condition, so authenticated users were blocked and anonymous visitors were let through.

A flowchart. An anonymous visitor with no session and a logged-in student with a real session both arrive at the same access check. The check lets the anonymous visitor through to 18,697 exam records and refuses the student, which is the reverse of what each one should have received.
The check ran on every request. It just answered every one of them backwards.Full explanationMermaid source

Reading the diagram

Two people arrive at the top, and the difference between them is the only input the check gets.

On the left, in red, is someone with no account who has never signed in. On the right, in grey, is a student with a real account and a live session. Both requests go to the same place.

The amber diamond in the middle is the check. Read its two outgoing edges together, because the pairing is the whole point:

  • No session, so let in. Should have refused.
  • Has session, so refuse. Should have let in.

Neither branch is a mistake on its own. A guard that refuses some requests and allows others is exactly what a guard does. It is only when you read both edges at once that you can see the conditional is wired to the wrong outcomes, and that is precisely why this class of bug survives a code review that goes function by function.

Follow the red path to the bottom left. The anonymous visitor lands in a database holding 18,697 records, 4,538 of them students at K-12 schools and universities including UC Berkeley and UC Davis. The student on the right lands in the grey box: locked out of an account that is theirs.

The app was more locked-down-looking than a correct one. If you were signed in, you got refused. Anyone building this app while logged into it would see a permission error and read it as the security working. The exposure was only visible from a browser with no session, which is the one state nobody tests.

Why an inverted check beats a missing one

A missing check is loud. Someone eventually notices that an endpoint returns data with no login at all, because the endpoint behaves identically for everyone and that uniformity is suspicious.

An inverted check is quiet, for three reasons that compound:

  1. It produces both outcomes. Some requests get refused, some get through, so the guard looks like it's discriminating. It is. Just on the wrong axis.
  2. It fails in the direction of the developer's blind spot. The person shipping the app is signed in. Their own experience of the bug is being denied, and denial reads as security.
  3. Automated tests pass. A test asserting that a protected route returns a response, or that an unauthorized caller gets a non-200, will pass against inverted logic depending on which session state the test runs in.

That third one is worth sitting with if you have tests and feel covered by them. A test written from a logged-out fixture that asserts "anonymous gets 200 on the public page" tells you nothing here.

The check that actually catches it

One rule, and it is not a scanner, a policy, or a library:

Test both states, every time. Signed out, then signed in.

Two minutes, no tooling. Open a private browsing window and, without signing in, try to load a page or hit an API route that should require an account. Seeing real data means your guard is inverted or absent. Then sign in and confirm you can still reach your own data. An inverted guard passes the first test and fails the second, which is why running only one of them misses it.

Adding Row Level Security does not by itself solve inversion, and it's worth being clear about that rather than reaching for the usual advice. RLS is a policy you write, and a policy can be written backwards exactly like a function can. What defeats inversion is checking the guard against both inputs, because inversion is the one bug class that a single-state test cannot see.

Where the two-checkpoint pattern does help is in making the intended answer explicit. When you separate authentication from authorization, each checkpoint has one job and one correct answer, and a swapped branch is easier to spot than it is inside a single function trying to decide everything at once.

The Mermaid source

Copy this and swap in your own guard.

flowchart TB
    ANON["Anonymous visitor<br/>no account, never signed in"]
    USER["Logged-in student<br/>real account, real session"]
    GUARD{"<b>Are you signed in?</b><br/>the check the AI wrote"}
    DATA[("18,697 exam records<br/>4,538 of them students")]
    DENY["Locked out of their own account"]

    ANON -->|"no session found"| GUARD
    USER -->|"session found"| GUARD
    GUARD -->|"no session, so let in<br/>should have refused"| DATA
    GUARD -->|"has session, so refuse<br/>should have let in"| DENY

    classDef public fill:#fef2f2,stroke:#ef4444,stroke-width:2px,color:#1c1917
    classDef gate fill:#fffbeb,stroke:#f59e0b,stroke-width:2px,color:#1c1917
    classDef store fill:#f5f5f4,stroke:#57534e,stroke-width:2px,color:#1c1917

    class ANON,DATA public
    class GUARD gate
    class USER,DENY store

    linkStyle 2 stroke:#ef4444,stroke-width:3px

What is inverted authentication logic?

It is an access check that runs correctly and returns the opposite answer. The condition is right, the branches are swapped. Instead of refusing requests with no session, the code refuses requests that have one. Nothing errors and nothing crashes, so every automated test that only asks whether the endpoint responds will pass.

Why did nobody notice the app was backwards?

Because the owner was almost certainly logged in while building it. From a signed-in browser the app looks locked down: you hit the guard and get refused, which reads as the security working. The failure only appears in a logged-out window, and testing signed-out is the step people skip.

How do I check my own app for this in two minutes?

Open your app in a private browsing window, do not sign in, and try to reach a page or API route that should require an account. If you see real data, your guard is inverted or absent. Then sign in and confirm you can still reach your own data. Both halves matter, because an inverted check fails the second test, not the first.

Would Row Level Security have caught this?

Not on its own. RLS is a policy you write, so an inverted policy is just as possible as an inverted function, and in this incident the access control was implemented in Supabase remote procedure calls rather than left to the database. What catches inversion is testing both states, signed in and signed out, rather than adding another layer that can also be written backwards.

Is this specific to AI-generated code?

The bug is old and humans write it too. What is specific to AI codegen is the volume and the review gap. A generator optimizing for code that runs will happily produce a guard that runs and is backwards, and the person shipping it did not write the logic, so they have no mental model to check it against.

Would your app pass the logged-out test?

A scan hits your app from outside with no session at all, which is exactly the state an inverted guard lets through.

diagrams

The Guard That Ran Backwards (Diagram)