Rebounder Tech Blog

Written by the people who actually run these systems in production.

RLS On Is Not Safe While TRUNCATE Is Granted

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

PostgreSQL's TRUNCATE is not subject to RLS policies, so a table with RLS enabled and zero policies can still be emptied outright if anon/authenticated retain the TRUNCATE privilege.

The short version

You can enable RLS (Row Level Security), the mechanism that restricts per-row access with policies, write not a single policy, and believe anon / authenticated are locked out — and the table can still be emptied outright if the TRUNCATE privilege remains on it. TRUNCATE is not an operation RLS policies apply to, and Supabase’s default privileges arrive including TRUNCATE unless you explicitly take it away.

What it looks like

There is a table holding a chatbot’s knowledge base (chat_knowledge). Its contents go straight into the LLM’s system prompt, so whoever can write to it decides what the bot says. The intent at design time is written into the table comment.

-- Chatbot knowledge base (RAG)
--
-- ⚠ The contents of this table go straight into the LLM system prompt.
--   Whoever can write here decides what the bot says. Enable RLS and create
--   no policy at all = anon / authenticated can neither read nor write.
--   Only the service role (the server-side API routes) touches it.

Run ENABLE ROW LEVEL SECURITY, write no CREATE POLICY. RLS is deny-by-default, so with zero policies no role passes SELECT or INSERT — that was the design’s reasoning. But while testing against real data on a local Supabase, we noticed anon / authenticated still held the TRUNCATE privilege. The RLS policy count was still zero.

Why

What PostgreSQL’s RLS policies cover is the row-level operations SELECT / INSERT / UPDATE / DELETE; TRUNCATE is not among them. Emptying a table outright is a table-level operation, and the RLS machinery that decides per-row visibility and writability never engages. The only thing decided is whether that role holds the TRUNCATE privilege, which is ordinary table permission (GRANT/REVOKE) territory, independent of RLS.

And this table had TRUNCATE on anon / authenticated despite nobody remembering granting it. Supabase gives roles broad privileges (equivalent to GRANT ALL) by default when a table is created, and with nothing specified TRUNCATE is included. The design’s reasoning — “RLS is on and there are zero policies, so it is safe” — never accounted for the existence of an operation RLS does not reach.

Fixing it

We stripped every privilege on the table from anon / authenticated, and explicitly re-granted only what service_role, the role that actually accesses it, needs.

-- ⚠ Grant privileges explicitly. Relying on Supabase's default privileges
--   (ALTER DEFAULT PRIVILEGES) means they attach or not depending on which role
--   ran the migration. Confirmed on local Supabase 2026-08-16: running from the CLI
--   left service_role without privileges and chat 502'd with
--   permission denied for table chat_knowledge.
-- ⚠ Strip everything from anon / authenticated. Supabase's default is GRANT ALL,
--   so left alone it includes TRUNCATE. **TRUNCATE bypasses RLS**, so
--   "RLS is enabled, therefore safe" does not hold (confirmed locally 2026-08-16).
REVOKE ALL ON public.chat_knowledge FROM anon, authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.chat_knowledge TO service_role;

REVOKE ALL strips every privilege including TRUNCATE from anon / authenticated, and service_role is explicitly GRANTed only the four operations it needs. The TRUNCATE hole that zeroing out RLS policies cannot close has to be closed on the ordinary privilege side, not in the RLS configuration.

The same commit also fixed a gap on the service_role side. Supabase’s default privileges behave differently depending on which role ran the migration, and in local testing, running from the CLI left service_role without privileges, so chat 502’d with permission denied for table chat_knowledge instead. Since “leave it to the Supabase default” is a foundation that drifts in both directions — too much and too little — we changed policy to state privileges explicitly with GRANT / REVOKE every time.

Preventing a repeat

Enabling RLS with zero policies works correctly for the four operations SELECT / INSERT / UPDATE / DELETE. But concluding from that alone that “this table is locked” is premature: table-level operations such as TRUNCATE, DROP and ALTER TABLE need their privileges checked as a separate category. The more a table has RLS on it, the more readily the assumption “this must be safe now” takes hold, and the more easily the chance to review ordinary GRANT/REVOKE is walked past. On a platform like Supabase where default privileges arrive broad, checking table by table that anon / authenticated hold no unnecessary privileges is a habit worth having, RLS or not.

よくある質問

Q1Why isn't enabling RLS with zero policies enough?

RLS policies act only on row-level operations — SELECT, INSERT, UPDATE, DELETE — and do not apply to TRUNCATE. With no policy at all, holding the TRUNCATE privilege is enough to empty the whole table, and RLS never enters into it.

Q2Why did anon/authenticated have TRUNCATE in the first place?

We never granted it deliberately. Supabase's default privileges give roles the equivalent of GRANT ALL when a table is created, and TRUNCATE is part of that. On the assumption that enabling RLS made the table safe, the privileges themselves were never reviewed.

Q3Is the explicit GRANT to service_role a separate issue from TRUNCATE?

A separate problem. Whether Supabase's default privileges attach depends on which role runs the migration, and in local testing service_role ended up without them, so chat failed with permission denied and a 502. We moved to writing privileges explicitly rather than relying on defaults.

Q4How was this found?

While running Supabase locally and testing with real embeddings and answers, we went through the privileges table by table. It was one of four defects; the others were different in kind, such as the 502 and a too-loose search threshold.

確認した環境

  • Supabase (@supabase/supabase-js ^2.53.0) / local environment
  • Found and fixed on 2026-08-16 while testing against real data on local Supabase

この記事の根拠

  • SQLファイル 1〜9行目コミット 47a8735
  • SQLファイル 37〜47行目コミット 47a8735

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。