# Can users get your paid features for free?

Checked on 1 November 2026

Yes, if the page in the browser decides that a payment happened, or if users can edit their own plan, credits, role or “paid” flag in the database: then anyone can give themselves paid features with one request.

To fix it, let only your server decide: a payment provider’s webhook, such as Stripe’s, verified on your server, sets the paid state, and your database rules stop users from writing plan, credits, role or paid columns.

LidTight’s deep check found exactly this on Tidybook, our demo app built with Lovable, on 1 November 2026: a normal test user changed its own booking from “unpaid” to “paid” without paying.

## Why this happens in apps built with AI

In apps that use Supabase, including Lovable Cloud, the page in the browser sends its requests straight to the database, through Supabase’s API, with a [public key](https://lidtight.com/guides/supabase-public-key) and the user’s sign-in. Row level security (RLS: rules inside the database that decide which rows each user may read or change) is what stands between a user and your tables ([Supabase](https://supabase.com/docs/guides/database/postgres/row-level-security)).

Your users run that page on their own computers. Anything the page can send, they can send too, with any value they like. So two shortcuts give paid features away:

- **The page marks the order as paid.** After checkout, a success page writes “paid” to the database. The same request works without paying. Stripe also says you cannot rely on that page alone, because a customer can pay and never reach it ([Stripe](https://docs.stripe.com/checkout/fulfillment)).
- **Users can edit every column of their own row.** A rule such as “users can update their own profile” is about rows, not columns. Supabase’s docs say such a rule gives the owner “full access to update the row, including all of the columns” ([Supabase](https://supabase.com/docs/guides/database/postgres/column-level-security)). That includes plan, credits, role, is_admin and paid.

Both shortcuts work in a demo, so nothing looks wrong until someone tries. Lovable’s own Deep scan has a “Payments & billing” area for this: prices or paid features that a customer can change, or reach without paying ([Lovable](https://docs.lovable.dev/features/security)).

## What it looked like on Tidybook

Tidybook is a cleaning-booking app that we built with Lovable, with known problems, to test LidTight’s checks. Its “Pay” button opens a success page, and that page sets the booking to “paid” from the browser.

LidTight’s deep check on Tidybook, on 1 November 2026, signed up two new test users and found this:

- User B could not see user A’s bookings row.
- When user B tried to change user A’s data, the server blocked it.
- **Critical:** “An order in bookings can be marked as paid without payment”. Test user A changed its own bookings row from “unpaid” to “paid”. No payment was made.

The ownership rule worked: user B could not touch user A’s booking. But nothing stopped the owner from changing the paid column of their own booking. Rules about whose row it is do not protect what is inside the row. For the first half of the problem, see the guide on [one customer seeing another’s data](https://lidtight.com/guides/one-customer-sees-another).

## How to check your own app

1. **List the columns that decide what a user gets.** Look for names such as paid, payment_status, status, plan, tier, credits, balance, role, is_admin and subscription_status. You can ask your AI builder: “List every database column that controls payment, plan, credits or admin rights, and every place in the code that changes it.”
2. **Find out who writes each one.** If a page, a button or the success page after checkout writes the value, any user can write it too. A value that only your server writes, after it checks the payment with the provider, is fine.
3. **Ask the database.** In your own Supabase project, open the SQL editor and run the query below. It uses the Postgres function [has_column_privilege](https://www.postgresql.org/docs/current/functions-info.html). Each row is a column that signed-in users may set or change. If that table lets owners add or update their own rows, users can set that column on their own rows. If the table has no row level security at all, it is worse: Supabase says such a table can be read and changed by any role that has a grant (a right) on it ([Supabase](https://supabase.com/docs/guides/database/postgres/row-level-security)). Some rows are harmless, such as the status of a to-do item.
4. **Run your builder’s own scan.** In Lovable, the Deep scan is free and reviews your code for payment problems ([Lovable](https://docs.lovable.dev/features/security)). Reading code shows what the app is meant to do. A test on the live app shows what the database really allows. See [Is your AI builder’s own scan enough?](https://lidtight.com/guides/is-builder-scan-enough)
5. **Test the live app as a normal user.** Use a test account on your own app, never a real customer’s. LidTight’s deep checks do this test for you.

```
-- Run in the SQL editor of your own Supabase project.
-- Lists columns about money or access that signed-in users may set or change.
select table_name, column_name
from information_schema.columns
where table_schema = 'public'
  and column_name ~* '(paid|payment|status|plan|tier|credit|balance|role|admin|premium|subscri)'
  and has_column_privilege(
    'authenticated',
    format('public.%I', table_name),
    column_name::text,
    'INSERT, UPDATE'
  );
```

## The fix: only the server decides

The rule is simple. The browser may ask for a paid feature. Only your server may grant it, and only after the payment provider confirms the payment. Here is how that works with Stripe and Supabase.

1. **Let Stripe tell your server.** Set up a webhook: a message that Stripe’s servers send to your server when a payment completes. Listen for checkout.session.completed. If you accept payment methods that confirm later, such as bank transfers, also listen for checkout.session.async_payment_succeeded. Stripe says webhooks are required to deliver what customers paid for ([Stripe](https://docs.stripe.com/checkout/fulfillment)).
2. **Check every webhook’s signature.** Your server checks the Stripe-Signature header with the endpoint’s signing secret (it starts with whsec_) and the raw request body. Without this check, Stripe warns, someone could send fake events that fulfill orders or grant account access ([Stripe](https://docs.stripe.com/webhooks)). Supabase has an [example server function](https://supabase.com/docs/guides/functions/examples/stripe-webhooks) that does this check.
3. **Grant once, from Stripe’s data.** Your server asks Stripe for the Checkout Session and checks its payment_status before it grants anything ([Stripe](https://docs.stripe.com/checkout/fulfillment)). Stripe may send the same event more than once, so save each ID you handled and skip repeats ([Stripe](https://docs.stripe.com/webhooks)).
4. **Keep paid state where users cannot write it.** Put payments, plans, credits and roles in a table that signed-in users can only read. Your server writes it with the Supabase secret key, which skips row level security and must never reach the browser ([Supabase](https://supabase.com/docs/guides/database/postgres/row-level-security)). For user roles, Supabase recommends a separate table like this over column-level rules ([Supabase](https://supabase.com/docs/guides/database/postgres/column-level-security)). The same idea works for payments. The SQL below is an example.
5. **Close the old path.** Delete the browser code that writes “paid”, plan or credits. Then take away users’ right to change the old columns: remove the columns, or use Supabase’s [column privileges](https://supabase.com/docs/guides/database/postgres/column-level-security) so that users can update only safe columns. Supabase calls column privileges an advanced feature. Check new rows too: a user must not be able to create an order that is already “paid”.
6. **Spend value on the server.** When a user spends a credit or starts a trial, the page calls a server function. The function checks the rules and changes the balance. The page never writes the balance itself.
7. **Test again as a normal user.** Run the query above again, and test the live app with a test account.

```
-- Payments live in their own table. Only your server writes here,
-- from the verified Stripe webhook, with the secret key.
create table public.payments (
  id bigint primary key generated always as identity,
  user_id uuid not null references auth.users,
  order_id uuid not null,                  -- your order or booking
  stripe_session_id text not null unique,  -- one grant per payment
  created_at timestamptz not null default now()
);

alter table public.payments enable row level security;
revoke all on table public.payments from anon, authenticated;
grant select on table public.payments to authenticated;
grant select, insert on table public.payments to service_role;

-- Signed-in users may read their own payments. No rule lets them write.
create policy "Users can read their own payments"
  on public.payments for select to authenticated
  using ((select auth.uid()) = user_id);
```

> You can still use your success page. Stripe suggests that your server runs the same check when the customer lands there, so access is instant. That is safe when the page only passes the Checkout Session ID, and your server asks Stripe whether it is paid ([Stripe](https://docs.stripe.com/checkout/fulfillment)).

## What LidTight can and cannot check

LidTight’s [free check](https://lidtight.com/#check-now) reads only what any visitor’s browser can see, in seconds. It shows, for example, whether a stranger can read your tables. It does not sign in, so it cannot test paid features.

The deep checks test the app as real users. They sign up two test users. User A tries to mark its own test order as paid, and to change plan, credits, role or admin columns on its own profile. User B tries to read and change user A’s data. The checks create two test users and one test order in your app, and change only that test data.

LidTight runs the deep checks only when the app’s owner asks. Under the app link, tick the box for the deep checks, then click “Check my app”. Both checks run in one press, and the results show on the page.

The deep checks have limits. They work on apps that use Supabase, including Lovable Cloud, and do not support Base44 or Firebase yet. They find paid columns by common names, so a column with an unusual name can be missed. A clean result is not proof, so do the checks above as well. For the rest of what to check before you go live, see the [launch security checklist](https://lidtight.com/guides/launch-security-checklist).

## Sources

1. [Stripe: Fulfill orders](https://docs.stripe.com/checkout/fulfillment)
2. [Stripe: Receive Stripe events in your webhook endpoint](https://docs.stripe.com/webhooks)
3. [Supabase: Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
4. [Supabase: Column Level Security](https://supabase.com/docs/guides/database/postgres/column-level-security)
5. [Supabase: Handling signed Stripe webhooks with Edge Functions](https://supabase.com/docs/guides/functions/examples/stripe-webhooks)
6. [Lovable: Security](https://docs.lovable.dev/features/security)
7. [PostgreSQL: System information functions (has_column_privilege)](https://www.postgresql.org/docs/current/functions-info.html)
