Why AI-Built Apps Break · For engineers

Lovable's April 2026 incident: the access test to write

A regression re-opened public Lovable projects' chat and code for 11 weeks, and a stale triage brief closed the reports. The test and habit that catch both.

Short answer From 3 February to 20 April 2026, a regression let any Lovable user with a link read public projects' chat history and code. Reports were closed because the triage brief called it intended. An access-matrix test and a brief generated from it address both failures.

Lovable's April 2026 exposure was reported through HackerOne eight weeks before it was fixed. Every report was closed, because the brief the triagers worked from still called the behavior intendedSource 1.

On 20 April a security researcher publicly reported that any signed-in Lovable user could read data inside other people's public projectsSource 1. Lovable shipped a fix within two hoursSource 1. Its postmortem, two days later, says the exposure ran from 3 February: the chat history and source code of public projects were readable by any Lovable user who had the project's linkSource 1. Private projects were never affectedSource 1.

The postmortem is unusually candid, and three of the failures it describes are not specific to AI app builders. A setting called "public" meant one thing to the company and another to its users. A backend change quietly re-opened access that had been closed on purpose. And the triage documentation still described the old behavior as intended, so the reports that should have caught it were closed. Any team with a permission model has all three ingredients. Below is how each happened, and a test and a documentation habit for the last two.

§1

The Lovable April 2026 incident, in order

Lovable's timeline, from its own postmortemSource 1
WhenWhat changed
November 2024Lovable launches. A public project exposes its code and chat history by design.
March 2025Chat history and code of public projects no longer reachable through the API; remixing stops copying chat history.
May 2025Enterprise users can no longer create public projects.
November 2025New projects on every tier are private by default.
February 2026Backend regressions re-enable access to chat history and code of public projects. Exposure runs from 3 February.
22 February 2026First HackerOne report. This and later reports are closed without escalation.
20 April 2026Public report; fix shipped within two hours.
22 April 2026Postmortem published. It lists making historically public projects private as done.

The exposed data was chat history and source code, so anything a builder pasted into the chat, such as an API key, was exposed with it. If you had a public Lovable project between 3 February and 20 April 2026, rotate every key that ever appeared in its chat or its code. Lovable has since made all historically public projects private, except its official templatesSource 1. In April it said it was still reviewing which public projects other users may have viewed, and would contact their ownersSource 1.

§2

Failure one: "public" had two meanings

When Lovable launched, one Public or Private choice covered everything: a public project's code and chat history were open, it could be remixed, and it was shown on the owner's profileSource 1. Today Lovable has two independent settingsSource 1. Project visibility controls who can open the project in the editor, including its source code, chat history and unpublished changesSource 1. Website access controls who can visit the published app. On the free tier, every project used to be public by default, and going private needed a paid planSource 1.

Many users read "public" as "my published app is on the web", not as "anyone can read how I built it"Source 1. That is a reasonable reading. Many people who build with Lovable want a public app, and nothing in the word says "and the conversation that produced it".

Lovable says it is redesigning the visibility UXSource 1, and that is where the fix belongs: in the label, not the documentation. Name a setting after what it exposes and to whom: "Who can open this project's code and chat" and "Who can visit the live app". A bare "Public" toggle leaves the user to guess the object, and they will guess the one they care about.

§3

Failure two: a regression re-opened a closed door

Through 2025 Lovable removed access step by step. Then, in its own words, a backend regression "reintroduced access to chat histories on public projects — undoing protections we had deliberately put in place between March and November 2025"Source 1. It adds that "a change that should have been caught slipped through"Source 1.

OWASP's Top 10 for 2025 keeps broken access control at number one, found in some form in 100% of the applications in its test dataSource 2. Its cheat sheet on automating authorization tests names the usual cause: features are added or changed in later releases without anyone checking their effect on authorizationSource 3.

When a team removes a permission on purpose, the removal lives in code, but often nothing asserts it. The next refactor of the read path has no reason to know that "public" once stopped meaning "readable by everyone". A test that states the rule does.

An access control matrix as a regression test

OWASP recommends keeping the authorization matrix in a machine-readable form rather than a spreadsheet, so the tests can be generated from itSource 4, and including functional access control in unit and integration testsSource 2. The smallest version is a list of rows: who, what, and whether they may read it. One test per row. Below is a toy model of the two settings in which, as after Lovable's fix, a public project's code and chat stay with its owner, with the same matrix in ExUnit and in Node's built-in test runner.

Illustrative

Elixir

defmodule Projects do
  # Only the website setting opens anything to others; a public project shares no code or chat.
  def can_read?(%{website: :public}, _user, :live_app), do: true
  def can_read?(%{owner: owner}, owner, _resource) when not is_nil(owner), do: true
  def can_read?(_project, _user, _resource), do: false
end

defmodule AccessMatrixTest do
  use ExUnit.Case, async: true

  # A project anyone may find and whose app anyone may visit.
  @project %{owner: "ann", visibility: :public, website: :public}

  # {who, what, allowed?} One row per decision; a removed permission becomes a false row.
  @matrix [
    {"ann", :chat_history, true},
    {"ann", :source_code, true},
    {"bob", :chat_history, false},
    {"bob", :source_code, false},
    {nil, :chat_history, false},
    {nil, :source_code, false},
    {"ann", :live_app, true},
    {"bob", :live_app, true},
    {nil, :live_app, true}
  ]

  for {user, resource, allowed} <- @matrix do
    test "#{user || "anonymous"} reads #{resource}: #{allowed}" do
      assert Projects.can_read?(@project, unquote(user), unquote(resource)) == unquote(allowed)
    end
  end
end

Node

// projects.js
export function canRead(project, user, resource) {
  if (resource === 'liveApp' && project.website === 'public') return true
  return user != null && user === project.owner
}

// access-matrix.test.js
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { canRead } from './projects.js'

const project = { owner: 'ann', visibility: 'public', website: 'public' }

// [who, what, allowed?] One row per decision; a removed permission becomes a false row.
const matrix = [
  ['ann', 'chatHistory', true],
  ['ann', 'sourceCode', true],
  ['bob', 'chatHistory', false],
  ['bob', 'sourceCode', false],
  [null, 'chatHistory', false],
  [null, 'sourceCode', false],
  ['ann', 'liveApp', true],
  ['bob', 'liveApp', true],
  [null, 'liveApp', true],
]

for (const [user, resource, allowed] of matrix) {
  test(`${user ?? 'anonymous'} reads ${resource}: ${allowed}`, () => {
    assert.equal(canRead(project, user, resource), allowed)
  })
}
Fig. 1 — Run on Elixir 1.20.4 and Node 24.21: 9 of 9 rows pass. It shows the idea; it isn't production code.

Then we added the one clause a refactor could plausibly add, a shortcut that treats a public project as readable by anyone: def can_read?(%{visibility: :public}, _user, _resource), do: true. Four rows failed, in both languages, and each failure names the door that opened. ExUnit reports bob reads chat_history: false, bob reads source_code: false, anonymous reads chat_history: false and anonymous reads source_code: false; Node prints the same four in camelCase. The rows for the live app still passed, which is correct: the app was meant to be public all along.

Two habits make the matrix work. Every pull request that removes access adds the matching false row in the same change, so the removal is a ratchet rather than a fact someone has to remember. And the matrix runs against the real read paths, not only the policy function: the API, the editor, remix or copy, export, and any background job that serializes a project. A deny rule that one endpoint forgets is still a leak. In an integration suite each row becomes a request: sign in as the actor (or send no session for anonymous), call the endpoint, expect 200 for true and 401, 403 or 404 for false. For a Supabase app, where the rules live in the database, our Row Level Security tests show the same idea at the database level.

§4

Failure three: the triage brief still said "intended"

Researchers reported the issue through HackerOne, the first time on 22 February. Every report was closed without reaching Lovable's security teamSource 1. The triagers were following documentation Lovable had given them, which "still described public project chat visibility as intended behavior"Source 1.

The triagers did what the brief said. Lovable takes responsibility: it says it is responsible for "equipping them with accurate, up-to-date information about what constitutes expected behavior on our platform"Source 1. It also says it is restructuring its escalation workflow so that any product change affecting user data access automatically triggers an update to the triage documentationSource 1.

The postmortem does not say how that trigger will work. One way to make it hold is to make the brief and the test the same artifact. If CI regenerates the "expected behavior" section of your disclosure brief from the matrix file on every merge, it cannot say "other users can read chat history" while a test asserts they cannot. And any report closed as "intended behavior" that concerns who can read user data deserves a second look from whoever owns the permission model, because that is exactly the closure a stale brief produces.

§5

What to do this week

  1. Write the matrix down. Actors down the side (owner, another user with the same role, another tenant, anonymous), resources across the top, and the expected answer in each cell.
  2. Generate one test per cell and run them on every pull request, against each path that returns the data.
  3. Make every removal of access add a false row in the same pull request.
  4. Rename settings after what they expose. "Public" with no object breeds support tickets, and sometimes incidents.
  5. Generate the expected-behavior section of your triage brief from the matrix, and re-check any "intended" closure that touches user data.
  1. The access matrix is a test fixture in the repo, not a spreadsheet Owner, same-role user, other tenant, anonymous.
  2. Each row is a test that runs on every pull request
  3. Removing access adds a false row in the same change
  4. Settings say what they expose and to whom
  5. The triage brief is generated from the matrix

Sources

  1. Our response to the April 2026 incident (Lovable, Apr 22, 2026). Retrieved . ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩
  2. A01:2025 Broken Access Control (OWASP Top 10:2025). Retrieved . ↩ ↩
  3. Authorization Testing Automation Cheat Sheet (OWASP). Retrieved . ↩
  4. Authorization Regression Testing Cheat Sheet (OWASP). Retrieved . ↩

Published · Updated Report an error (support@agentum.cloud)