Your internal tool is reachable by the whole internet
Internal tools inherit a dangerous adjective. Because only colleagues should touch them, teams reason, exposure hardly matters — and then the tool ships on the same public Supabase URL as everything else, reached with the same public anon key, protected by whatever policies happened to be written. The internet does not respect the word internal; it respects configuration.
The classic casualty is the ticketing table behind an ops dashboard: ticket ids, reporter emails, severities, sometimes customer names pasted into descriptions. With RLS disabled, or policies granting the anon role by oversight, that queue reads like a company diary. Nothing warns anybody, because every request involved succeeds politely.
This is precisely the failure class RowShield automates: the ANON_TABLE_READABLE rule asks, behaviourally, whether the anonymous key can pull rows from each reachable table, and reports the tables where it can. The rest of this page covers why it happens and how to close it.
Rules that check this
- criticalTable readable with the anon key
ANON_TABLE_READABLE
Why internal ends up world-readable
The mechanism is ordinary. PostgREST serves every table in the public schema to whoever presents the project URL and anon key, and neither credential is secret. An ops tool built quickly often skips policy writing entirely — the dashboard worked in testing because the developer’s session was the table owner, and owners bypass RLS by default.
Ownership explains a lot of false confidence. During development, queries succeed for the person building them, so the tool seems locked down; in production, colleagues authenticate and see their data, so it seems locked down again. Only the anonymous path — the one nobody exercises manually — is open, and it stays open until probed deliberately.
What the probe actually does
Checking configuration is necessary but insufficient, because policies interact in ways the eye misreads. RowShield therefore probes: for each reachable table it issues a read as the anonymous caller and classifies the outcome — rows returned, empty payload, or explicit permission denial. Rows returned is an unambiguous finding; ambiguous middle cases get recorded conservatively rather than waved away.
The probe is deliberately polite. It issues GET requests only, refuses to connect to private network addresses, and touches nothing beyond what any anonymous visitor could already fetch. Combined with catalogue-only configuration reads — RowShield never copies your data — the scan learns posture without becoming itself a risk to the tables it watches.
Closing the door on the tickets table
Remediation is the standard trio pointed at the right role. Enable RLS, force it, and address policies to authenticated so the anon role receives nothing by omission. If colleagues truly need unauthenticated reads — rare, and usually a design smell — grant that explicitly over a narrowed shape, never by leaving the table bare:
ALTER TABLE public.tickets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tickets FORCE ROW LEVEL SECURITY;
CREATE POLICY "staff_read_assigned_tickets"
ON public.tickets
FOR SELECT
TO authenticated
USING (
assignee_id = (SELECT auth.uid())
OR team_id IN (
SELECT tm.team_id FROM public.team_members tm
WHERE tm.member_user_id = (SELECT auth.uid())
)
);Making it someone else’s job
Re-probe after deploying. The satisfying outcome is a permission error where rows used to be, followed by a colleague confirming the dashboard still works for signed-in staff. Keep the probe in rotation permanently: the failure returns whenever a migration adds a table and the policy pair is forgotten again.
Internal tools lack owners by construction — everybody’s job and nobody’s — which is why behavioural monitoring beats reminders here. Scans run daily on Free, hourly on Indie and every fifteen minutes on Team, alert destinations hear about changes only, configuration reads touch catalogue metadata exclusively, and RowShield is an independent product, unaffiliated with Supabase.
Frequently asked
- Is the anon key really usable by outsiders?
- Yes, and by design: it ships in frontend bundles and appears in view-source, so assume every public URL’s anon key is public knowledge. Security therefore lives in what the key may do, which is exactly what RLS policies bound — and what an unpoliced table abandons entirely.
- Our tool sits behind a VPN. Still exposed?
- Almost certainly, unless the VPN terminates in front of the database layer. The REST endpoint is a public URL; reaching it requires no office network. Network restrictions around your interface do nothing to a stranger querying the API directly, which is why table policy is the control that counts.
- Why classify an empty response as ambiguous?
- An empty array is consistent both with correct filtering and with a table that happens to be empty, and the probe cannot distinguish intent from accident. Recording reachable-but-unproven keeps such tables on the watchlist until a policy change or seeded data settles the question.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit