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.
The Lovable April 2026 incident, in order
| When | What changed |
|---|---|
| November 2024 | Lovable launches. A public project exposes its code and chat history by design. |
| March 2025 | Chat history and code of public projects no longer reachable through the API; remixing stops copying chat history. |
| May 2025 | Enterprise users can no longer create public projects. |
| November 2025 | New projects on every tier are private by default. |
| February 2026 | Backend regressions re-enable access to chat history and code of public projects. Exposure runs from 3 February. |
| 22 February 2026 | First HackerOne report. This and later reports are closed without escalation. |
| 20 April 2026 | Public report; fix shipped within two hours. |
| 22 April 2026 | Postmortem 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.
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.
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.
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
endNode
// 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)
})
}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.
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.
What to do this week
- 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.
- Generate one test per cell and run them on every pull request, against each path that returns the data.
- Make every removal of access add a false row in the same pull request.
- Rename settings after what they expose. "Public" with no object breeds support tickets, and sometimes incidents.
- Generate the expected-behavior section of your triage brief from the matrix, and re-check any "intended" closure that touches user data.
- The access matrix is a test fixture in the repo, not a spreadsheet Owner, same-role user, other tenant, anonymous.
- Each row is a test that runs on every pull request
- Removing access adds a false row in the same change
- Settings say what they expose and to whom
- The triage brief is generated from the matrix
Next step
Get new posts as they come out
Every new post, in plain English and in depth. Follow the RSS feed.
Sources
- Our response to the April 2026 incident (Lovable, Apr 22, 2026). Retrieved . ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩ ↩
- A01:2025 Broken Access Control (OWASP Top 10:2025). Retrieved . ↩ ↩
- Authorization Testing Automation Cheat Sheet (OWASP). Retrieved . ↩
- Authorization Regression Testing Cheat Sheet (OWASP). Retrieved . ↩