Troubleshooting guide

My app's database is readable by anyone: how to find and fix it

A live app answers requests it should refuse. An anonymous visitor reads a table, one user opens another user's record, a tool server lists its tools to anyone, or bots fill a public form until your inbox is useless. In our data, this is one of the most common ways an app breaks after launch: 32 verified builder reports across Supabase, Firebase, Next.js, self-hosted servers and apps generated by AI builders.

32 verified cases13 min read Updated 18 Oct 2026Last reviewed 19 Oct 2026How we collect cases

What you'll see

  • An anonymous request, with no cookie and no token, returns rows, files or a list of tools.
  • A second test account can see, edit or delete the first account's records by changing an ID.
  • A visitor has edited or deleted seeded or demo content.
  • Your inbox, calendar or webhook fills with form submissions and bookings you did not expect, or strangers receive mail from your domain.
  • After one person signs out and another signs in on the same browser, the second sees the first person's data.
  • Content you unpublished is still reachable by its URL.

Why it happens

Cause 1

Permissive defaults were never closed

Supabase says a table in an exposed schema without row-level security is readable and writable by any role with a grant on it, and that tables made in the SQL Editor need RLS enabled by hand. Firebase offers a test mode that allows anyone access, and its documentation notes that a deployed app is publicly accessible even before you launch it. A rule or setting that was fine on a laptop ships to production unchanged.

Cause 2

Rules were tested with one user

A policy can look right and still fail with two accounts. In Supabase, auth.uid() is null for unauthenticated requests, views bypass RLS by default, and the secret key bypasses RLS entirely. OWASP's authorization guidance says to check access for the specific object on every request, because access to one object does not imply access to all objects of that type.

Cause 3

The check lives in the wrong layer

A page-level login redirect does not protect the server action or route behind it. The Next.js documentation says Server Actions are reachable by a direct request and must verify authentication and authorization inside each action. CORS is not access control either: MDN describes it as a browser mechanism for sharing responses, and requests that look like a form submission reach the server regardless.

Cause 4

Public endpoints have no limit, no dedupe and no verified challenge

A waitlist, contact form or booking page that accepts any request will accept ten thousand. A bot challenge that only renders in the page protects nothing: Cloudflare's Turnstile documentation says the client-side widget alone does not protect your forms, and an attacker can submit any string to your endpoint. If the form also takes the recipient from the request, it can be used to send mail to strangers.

Cause 5

Servers and caches that were never told who is asking

Authorization is optional in the MCP specification, so a tool server started over HTTP with no token check answers everyone, and can return whatever secrets sit in its environment. Caches can fail the same way. MDN warns that personalized content stored in a shared cache can be retrieved by other users, and a client-side cache key without the user's ID does the same thing inside one browser.

How to fix it

  1. List every surface that answers without a login

    Write down each table or collection, storage bucket, API route, server action, webhook, admin page and tool server. For each, record who may read it and who may write it. Test the ones you cannot answer for first.

  2. Deny by default at the data layer

    OWASP's guidance is to configure the application to deny access by default and to prefer explicit configuration over framework defaults. In Postgres on Supabase, that means RLS on, no grant for anon, and one explicit policy for each access you want. In Firebase, start from locked rules (allow read, write: if false) and add per-user rules such as request.auth.uid == userId.

    alter table public.reviews enable row level security;
    revoke all on table public.reviews from anon;
    
    create policy "owner only" on public.reviews
      for all to authenticated
      using ((select auth.uid()) = user_id)
      with check ((select auth.uid()) = user_id);
  3. Check identity and ownership inside every handler

    Do not rely on a page redirect or on middleware alone. Each action or route confirms who is calling and that the object belongs to them. It returns the same refusal whether the object is missing or someone else's.

    "use server";
    export async function deleteReview(id: string) {
      const session = await auth();
      if (!session?.user) throw new Error("Unauthorized");
      const review = await db.review.findUnique({ where: { id } });
      if (!review || review.authorId !== session.user.id) throw new Error("Forbidden");
      await db.review.delete({ where: { id } });
    }
  4. Put authentication in front of tool servers and admin UIs, then rotate

    A tool server or self-hosted web UI reachable over HTTP needs token validation on every request, and an invalid or expired token should get a 401. If an endpoint ever returned environment variables, an admin token or files containing credentials, assume they were read and rotate them. Remove world-readable permissions from logs and scripts that hold credentials, too.

  5. Verify the bot challenge on the server and fix the recipient

    Call Turnstile's siteverify from your backend for every submission. Tokens are single-use and valid for 300 seconds, so a replayed token is refused. Never read the 'to' address of an outgoing email from the request body.

    const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET, response: token }),
    });
    const { success } = await res.json();
    if (!success) return new Response("Challenge failed", { status: 400 });
  6. Rate limit and dedupe every public write

    Limit requests per IP and per address at your host's firewall or in code. The Next.js guide recommends rate limiting expensive operations such as sending email or writing to a database. Let the database refuse duplicates, so a retry or a bot loop cannot create a second row or burn storage.

    create unique index waitlist_email_key on waitlist (lower(email));
    
    insert into waitlist (email) values ($1)
    on conflict (lower(email)) do nothing;
  7. Stop caches serving one person's data to another

    Mark personalized responses Cache-Control: no-cache, private, as MDN recommends. A cookie alone does not make a response private. Include the user's ID in every client-side cache key and clear that cache on sign-out. When you unpublish content, purge its path from the CDN as well as removing it from the origin.

    res.setHeader("Cache-Control", "no-cache, private");

Check it's fixed

  • Send each read and write request with no cookie and no token (curl is enough). Every one should return 401, 403 or an empty result, never data.
  • Create two accounts. As account B, try to read, update and delete account A's records by ID, in the UI and directly against the API. Each attempt should fail with 403 or 404.
  • Submit the public form 20 times from one machine, then with a reused or missing Turnstile token. The limiter should refuse the burst, the token check should refuse the replay, and a repeated address should leave one row.
  • Sign out and sign in as a different user in the same browser, then reload. Nothing from the first account should appear. Fetch an unpublished URL and confirm it no longer resolves.

Fix it with Gemmein

On Gemmein, collections your users write must have their allowed fields declared before the app writes there, and Development enforces this exactly as Live does. The app has no 'see everyone' option or role to branch on, admin work lives in the Gemmein dashboard, and a browser using the Live key from an unverified domain is refused.

  1. Declare each collection's rule and fields

    In the collection's file (for example gemmein/collections/feed.json), give it a rule and, for collections your users write (community, shared, direct), the fields a write may carry. A write with an undeclared field is 400 undeclared_field, a write to such a collection with no fields declared is 400 collection_not_declared, and npx gemmein check and go-live refuse while any of them has no fields declared.

    { "rule": "community", "means": "members post updates",
      "fields": {
        "body":   { "type": "text", "maxLength": 2000, "required": true },
        "title":  { "type": "text", "maxLength": 200 },
        "author": { "type": "link", "link": "profiles" }
      } }
  2. Keep admin views in the Gemmein dashboard

    When you sign in to your own app you are an ordinary customer, and writes are owner-scoped. There is no "see everyone" option in the app and no role to branch on (every session reports member); the Gemmein dashboard is the admin dashboard, and the docs say to build none of that into the app.

  3. Verify the domains your Live app answers

    Live accepts only the domains on the Domains page that are verified. A browser calling with the Live key from any other address is refused 403 origin_not_allowed, naming the address, and Errors lists such addresses.

  4. Set how each webhook receiver is verified

    A receiver's verify names the scheme (signature, stripe, svix or shared_token), the signature scheme also carries a recipe such as GitHub's below, and event_id in its map dedupes resent events. If the sender doesn't sign the event itself (a {token} recipe or shared_token), the relay only writes records and calls your URL by default; other actions answer 400 invalid_definition unless the relay sets trigger.trustUnsignedEvents: true.

    "verify": { "scheme": "signature", "header": "X-Hub-Signature-256", "signatureKey": "sha256" }

Questions

Doesn't CORS stop other sites reading my API?

It only controls whether a browser lets a script read a cross-origin response. MDN notes that requests resembling a form submission reach the server regardless, and tools such as curl ignore CORS entirely, so it cannot replace authentication and ownership checks.


Is it safe to ship my database's public key in the browser?

Supabase's documentation treats the publishable key as client-side and subject to RLS, so it is only as safe as your policies. The secret key bypasses RLS and must never reach the browser or customers.


Is a rule that requires any signed-in user enough?

Usually not. Firebase's documentation discourages leaving data open to every signed-in user and shows per-user rules for production, such as request.auth.uid == userId, for data that belongs to one person.


My tool server was open for a while. What now?

Add token validation, then rotate every secret and token it could have returned, including environment variables and admin tokens. Check its logs for requests you do not recognize.


Is authorization required for an MCP server?

The MCP specification calls authorization optional, so it is your job to turn it on. For an HTTP server, the specification says invalid or expired tokens must receive a 401, and that authorization applies to every request from client to server.


Sources

Last reviewed 19 Oct 2026. What changed: No changes: steps re-checked against 8 official page(s). Checked against: supabase.com, firebase.google.com, developer.mozilla.org, developer.mozilla.org, cheatsheetseries.owasp.org, developers.cloudflare.com, nextjs.org, modelcontextprotocol.io. How we check this

← Back to the full report