[{"data":1,"prerenderedAt":1471},["ShallowReactive",2],{"post-en-supabase-rls-multi-tenant":3},{"post":4,"translated":317,"related":1459,"translations":1466},{"id":5,"title":6,"body":7,"cover":1435,"date":1436,"description":1437,"extension":1438,"key":1439,"keywords":1440,"meta":1446,"navigation":317,"path":1447,"projects":1448,"seo":1450,"slug":1439,"stem":1451,"tags":1452,"updated":1456,"__hash__":1457,"minutes":259,"words":1458},"blog/blog/en/supabase-rls-multi-tenant.md","Supabase RLS for multi-tenant apps: one header, two rules",{"type":8,"value":9,"toc":1425},"minimark",[10,19,24,59,68,77,81,88,95,99,106,165,175,574,577,601,604,608,615,819,836,851,855,858,1009,1014,1038,1049,1065,1069,1084,1087,1307,1309,1346,1357,1363,1367,1381,1385,1404,1407,1421],[11,12,13,14,18],"p",{},"In MoneyKoi a budget book is a tenant: a household shares one book, and one person can be a member of several. For this multi-tenant setup I use Supabase RLS (row-level security) with a pre-request hook: before every request it checks the ",[15,16,17],"code",{},"X-Tenant-Id"," header against the caller's memberships and stores the active book. Reads are then limited to the active book, while writes are checked against the book each row belongs to. Lists stay fast, and bookings queued offline still go through after the user has opened another book.",[20,21,23],"h2",{"id":22},"a-book-is-a-tenant","A book is a tenant",[11,25,26,27,30,31,34,35,38,39,42,43,46,47,50,51,54,55,58],{},"The data model is plain. There is ",[15,28,29],{},"tenant"," (the book), ",[15,32,33],{},"profile"," (the person, global) and ",[15,36,37],{},"tenant_member"," with a role: ",[15,40,41],{},"owner",", ",[15,44,45],{},"admin"," or ",[15,48,49],{},"member",". Every data table – accounts, categories, bookings, receipts – has a ",[15,52,53],{},"tenant_id"," column. On sign-up a trigger on ",[15,56,57],{},"auth.users"," creates the profile, a personal book and the owner membership.",[11,60,61,62,67],{},"A person can have up to three books of their own and be invited to others by link (",[63,64,66],"a",{"href":65},"/en/projects/moneykoi","see the project","). On the phone and in the browser, switching between them is one tap.",[11,69,70],{},[71,72],"img",{"alt":73,"height":74,"src":75,"width":76},"The book switcher in MoneyKoi's demo mode: a private book, selected, and a shared book the person owns",711,"/images/blog/supabase-rls-multi-tenant/books.webp",320,[20,78,80],{"id":79},"why-the-tenant-id-is-not-in-the-jwt","Why the tenant ID is not in the JWT",[11,82,83,84,87],{},"A common approach in Supabase is to put the tenant ID into the JWT as a claim and read ",[15,85,86],{},"auth.jwt()"," in the policies. With one tenant per person that works. With several books per person, every switch would need a new token, and a token only ever knows one book.",[11,89,90,91,94],{},"MoneyKoi does store the user's own book in ",[15,92,93],{},"app_metadata"," on sign-up, but the book that is currently open travels with every request as a header. The header is a wish, not proof. The server decides whether it counts.",[20,96,98],{"id":97},"the-pre-request-hook","The pre-request hook",[11,100,101,102,105],{},"PostgREST can run a function before every request. It is registered on the ",[15,103,104],{},"authenticator"," role:",[107,108,113],"pre",{"className":109,"code":110,"language":111,"meta":112,"style":112},"language-sql shiki shiki-themes github-dark-default","ALTER ROLE authenticator SET pgrst.db_pre_request TO 'public.db_pre_request';\nNOTIFY pgrst, 'reload config';\n","sql","",[15,114,115,154],{"__ignoreMap":112},[116,117,120,124,127,131,134,138,141,144,147,151],"span",{"class":118,"line":119},"line",1,[116,121,123],{"class":122},"suJrU","ALTER",[116,125,126],{"class":122}," ROLE",[116,128,130],{"class":129},"sZEs4"," authenticator ",[116,132,133],{"class":122},"SET",[116,135,137],{"class":136},"sFSAA"," pgrst",[116,139,140],{"class":129},".",[116,142,143],{"class":136},"db_pre_request",[116,145,146],{"class":122}," TO",[116,148,150],{"class":149},"s9uIt"," 'public.db_pre_request'",[116,152,153],{"class":129},";\n",[116,155,157,160,163],{"class":118,"line":156},2,[116,158,159],{"class":129},"NOTIFY pgrst, ",[116,161,162],{"class":149},"'reload config'",[116,164,153],{"class":129},[11,166,167,168,171,172,174],{},"The function looks up the profile from ",[15,169,170],{},"auth.uid()",", reads the header and checks it against ",[15,173,37],{},". If the header is empty or the person is not (or no longer) a member, it falls back to the last book chosen, then to the user's own. Shortened:",[107,176,178],{"className":109,"code":177,"language":111,"meta":112,"style":112},"CREATE OR REPLACE FUNCTION public.db_pre_request()\n  RETURNS void LANGUAGE plpgsql STABLE SECURITY DEFINER\n  SET search_path = public\nAS $$\nDECLARE\n  v_profile uuid;\n  v_wanted  uuid;\n  v_active  uuid;\nBEGIN\n  SELECT id INTO v_profile FROM public.profile WHERE user_id = auth.uid();\n\n  BEGIN\n    v_wanted := NULLIF(current_setting('request.headers', true)::json ->> 'x-tenant-id', '')::uuid;\n  EXCEPTION WHEN others THEN\n    v_wanted := NULL;  -- not a uuid\n  END;\n\n  -- Honour the header only for a live membership.\n  SELECT tm.tenant_id INTO v_active\n  FROM public.tenant_member tm\n  WHERE tm.profile_id = v_profile AND tm.tenant_id = v_wanted AND tm.deleted_at IS NULL;\n\n  -- Fallbacks omitted: the last chosen book, then the user's own.\n\n  PERFORM set_config('request.tenant_id', COALESCE(v_active::text, ''), false);\n  PERFORM set_config('request.profile_id', COALESCE(v_profile::text, ''), false);\nEND;\n$$;\n",[15,179,180,195,215,230,239,245,251,257,263,269,312,319,325,362,377,394,402,407,413,431,446,494,499,505,510,537,560,568],{"__ignoreMap":112},[116,181,182,185,188,192],{"class":118,"line":119},[116,183,184],{"class":122},"CREATE OR REPLACE",[116,186,187],{"class":122}," FUNCTION",[116,189,191],{"class":190},"sc3cj"," public",[116,193,194],{"class":129},".db_pre_request()\n",[116,196,197,200,203,206,209,212],{"class":118,"line":156},[116,198,199],{"class":122},"  RETURNS",[116,201,202],{"class":129}," void ",[116,204,205],{"class":122},"LANGUAGE",[116,207,208],{"class":129}," plpgsql STABLE ",[116,210,211],{"class":122},"SECURITY",[116,213,214],{"class":129}," DEFINER\n",[116,216,218,221,224,227],{"class":118,"line":217},3,[116,219,220],{"class":122},"  SET",[116,222,223],{"class":129}," search_path ",[116,225,226],{"class":122},"=",[116,228,229],{"class":129}," public\n",[116,231,233,236],{"class":118,"line":232},4,[116,234,235],{"class":122},"AS",[116,237,238],{"class":129}," $$\n",[116,240,242],{"class":118,"line":241},5,[116,243,244],{"class":122},"DECLARE\n",[116,246,248],{"class":118,"line":247},6,[116,249,250],{"class":129},"  v_profile uuid;\n",[116,252,254],{"class":118,"line":253},7,[116,255,256],{"class":129},"  v_wanted  uuid;\n",[116,258,260],{"class":118,"line":259},8,[116,261,262],{"class":129},"  v_active  uuid;\n",[116,264,266],{"class":118,"line":265},9,[116,267,268],{"class":122},"BEGIN\n",[116,270,272,275,278,281,284,287,289,291,293,296,299,301,304,306,309],{"class":118,"line":271},10,[116,273,274],{"class":122},"  SELECT",[116,276,277],{"class":129}," id ",[116,279,280],{"class":122},"INTO",[116,282,283],{"class":129}," v_profile ",[116,285,286],{"class":122},"FROM",[116,288,191],{"class":136},[116,290,140],{"class":129},[116,292,33],{"class":136},[116,294,295],{"class":122}," WHERE",[116,297,298],{"class":129}," user_id ",[116,300,226],{"class":122},[116,302,303],{"class":136}," auth",[116,305,140],{"class":129},[116,307,308],{"class":136},"uid",[116,310,311],{"class":129},"();\n",[116,313,315],{"class":118,"line":314},11,[116,316,318],{"emptyLinePlaceholder":317},true,"\n",[116,320,322],{"class":118,"line":321},12,[116,323,324],{"class":122},"  BEGIN\n",[116,326,328,331,333,336,339,342,345,348,351,354,356,359],{"class":118,"line":327},13,[116,329,330],{"class":129},"    v_wanted :",[116,332,226],{"class":122},[116,334,335],{"class":136}," NULLIF",[116,337,338],{"class":129},"(current_setting(",[116,340,341],{"class":149},"'request.headers'",[116,343,344],{"class":129},", true)::",[116,346,347],{"class":122},"json",[116,349,350],{"class":122}," ->>",[116,352,353],{"class":149}," 'x-tenant-id'",[116,355,42],{"class":129},[116,357,358],{"class":149},"''",[116,360,361],{"class":129},")::uuid;\n",[116,363,365,368,371,374],{"class":118,"line":364},14,[116,366,367],{"class":129},"  EXCEPTION ",[116,369,370],{"class":122},"WHEN",[116,372,373],{"class":129}," others ",[116,375,376],{"class":122},"THEN\n",[116,378,380,382,384,387,390],{"class":118,"line":379},15,[116,381,330],{"class":129},[116,383,226],{"class":122},[116,385,386],{"class":122}," NULL",[116,388,389],{"class":129},";  ",[116,391,393],{"class":392},"sH3jZ","-- not a uuid\n",[116,395,397,400],{"class":118,"line":396},16,[116,398,399],{"class":122},"  END",[116,401,153],{"class":129},[116,403,405],{"class":118,"line":404},17,[116,406,318],{"emptyLinePlaceholder":317},[116,408,410],{"class":118,"line":409},18,[116,411,412],{"class":392},"  -- Honour the header only for a live membership.\n",[116,414,416,418,421,423,425,428],{"class":118,"line":415},19,[116,417,274],{"class":122},[116,419,420],{"class":136}," tm",[116,422,140],{"class":129},[116,424,53],{"class":136},[116,426,427],{"class":122}," INTO",[116,429,430],{"class":129}," v_active\n",[116,432,434,437,439,441,443],{"class":118,"line":433},20,[116,435,436],{"class":122},"  FROM",[116,438,191],{"class":136},[116,440,140],{"class":129},[116,442,37],{"class":136},[116,444,445],{"class":129}," tm\n",[116,447,449,452,454,456,459,462,464,467,469,471,473,475,478,480,482,484,487,490,492],{"class":118,"line":448},21,[116,450,451],{"class":122},"  WHERE",[116,453,420],{"class":136},[116,455,140],{"class":129},[116,457,458],{"class":136},"profile_id",[116,460,461],{"class":122}," =",[116,463,283],{"class":129},[116,465,466],{"class":122},"AND",[116,468,420],{"class":136},[116,470,140],{"class":129},[116,472,53],{"class":136},[116,474,461],{"class":122},[116,476,477],{"class":129}," v_wanted ",[116,479,466],{"class":122},[116,481,420],{"class":136},[116,483,140],{"class":129},[116,485,486],{"class":136},"deleted_at",[116,488,489],{"class":122}," IS",[116,491,386],{"class":122},[116,493,153],{"class":129},[116,495,497],{"class":118,"line":496},22,[116,498,318],{"emptyLinePlaceholder":317},[116,500,502],{"class":118,"line":501},23,[116,503,504],{"class":392},"  -- Fallbacks omitted: the last chosen book, then the user's own.\n",[116,506,508],{"class":118,"line":507},24,[116,509,318],{"emptyLinePlaceholder":317},[116,511,513,516,519,521,524,527,530,532,534],{"class":118,"line":512},25,[116,514,515],{"class":129},"  PERFORM set_config(",[116,517,518],{"class":149},"'request.tenant_id'",[116,520,42],{"class":129},[116,522,523],{"class":136},"COALESCE",[116,525,526],{"class":129},"(v_active::",[116,528,529],{"class":122},"text",[116,531,42],{"class":129},[116,533,358],{"class":149},[116,535,536],{"class":129},"), false);\n",[116,538,540,542,545,547,549,552,554,556,558],{"class":118,"line":539},26,[116,541,515],{"class":129},[116,543,544],{"class":149},"'request.profile_id'",[116,546,42],{"class":129},[116,548,523],{"class":136},[116,550,551],{"class":129},"(v_profile::",[116,553,529],{"class":122},[116,555,42],{"class":129},[116,557,358],{"class":149},[116,559,536],{"class":129},[116,561,563,566],{"class":118,"line":562},27,[116,564,565],{"class":122},"END",[116,567,153],{"class":129},[116,569,571],{"class":118,"line":570},28,[116,572,573],{"class":129},"$$;\n",[11,575,576],{},"Three details:",[578,579,580,584,594],"ul",{},[581,582,583],"li",{},"An invalid header leads to the fallback, not to an error. The response therefore says nothing about whether the other book exists.",[581,585,586,589,590,593],{},[15,587,588],{},"set_config(…, false)"," lasts for the whole session, not just the transaction. The hook runs before every request and overwrites the values, so no old value stays behind. ",[15,591,592],{},"true"," would be cleaner.",[581,595,596,597,600],{},"The real function also sets the role in the active book (",[15,598,599],{},"request.role",").",[11,602,603],{},"Because the book comes with every request, server and app agree even when the switch happened offline and the server never heard about it.",[20,605,607],{"id":606},"helper-functions-in-their-own-schema","Helper functions in their own schema",[11,609,610,611,614],{},"The policies read these values through small functions in an ",[15,612,613],{},"internal"," schema that the API does not expose:",[107,616,618],{"className":109,"code":617,"language":111,"meta":112,"style":112},"CREATE FUNCTION internal.current_tenant() RETURNS uuid\n  LANGUAGE sql STABLE SET search_path = ''\nAS $$ SELECT NULLIF(current_setting('request.tenant_id', true), '')::uuid $$;\n\nCREATE FUNCTION internal.is_member_of(p_tenant uuid) RETURNS boolean\n  LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''\nAS $$\n  SELECT EXISTS (\n    SELECT 1 FROM public.tenant_member tm\n    WHERE tm.tenant_id = p_tenant\n      AND tm.profile_id = internal.current_profile()\n      AND tm.deleted_at IS NULL)\n$$;\n",[15,619,620,639,659,683,687,703,724,730,740,759,775,798,815],{"__ignoreMap":112},[116,621,622,625,627,630,633,636],{"class":118,"line":119},[116,623,624],{"class":122},"CREATE",[116,626,187],{"class":122},[116,628,629],{"class":190}," internal",[116,631,632],{"class":129},".current_tenant() ",[116,634,635],{"class":122},"RETURNS",[116,637,638],{"class":129}," uuid\n",[116,640,641,644,647,650,652,654,656],{"class":118,"line":156},[116,642,643],{"class":122},"  LANGUAGE",[116,645,646],{"class":122}," sql",[116,648,649],{"class":129}," STABLE ",[116,651,133],{"class":122},[116,653,223],{"class":129},[116,655,226],{"class":122},[116,657,658],{"class":149}," ''\n",[116,660,661,663,666,669,671,673,675,678,680],{"class":118,"line":217},[116,662,235],{"class":122},[116,664,665],{"class":129}," $$ ",[116,667,668],{"class":122},"SELECT",[116,670,335],{"class":136},[116,672,338],{"class":129},[116,674,518],{"class":149},[116,676,677],{"class":129},", true), ",[116,679,358],{"class":149},[116,681,682],{"class":129},")::uuid $$;\n",[116,684,685],{"class":118,"line":232},[116,686,318],{"emptyLinePlaceholder":317},[116,688,689,691,693,695,698,700],{"class":118,"line":241},[116,690,624],{"class":122},[116,692,187],{"class":122},[116,694,629],{"class":190},[116,696,697],{"class":129},".is_member_of(p_tenant uuid) ",[116,699,635],{"class":122},[116,701,702],{"class":122}," boolean\n",[116,704,705,707,709,711,713,716,718,720,722],{"class":118,"line":247},[116,706,643],{"class":122},[116,708,646],{"class":122},[116,710,649],{"class":129},[116,712,211],{"class":122},[116,714,715],{"class":129}," DEFINER ",[116,717,133],{"class":122},[116,719,223],{"class":129},[116,721,226],{"class":122},[116,723,658],{"class":149},[116,725,726,728],{"class":118,"line":253},[116,727,235],{"class":122},[116,729,238],{"class":129},[116,731,732,734,737],{"class":118,"line":259},[116,733,274],{"class":122},[116,735,736],{"class":122}," EXISTS",[116,738,739],{"class":129}," (\n",[116,741,742,745,748,751,753,755,757],{"class":118,"line":265},[116,743,744],{"class":122},"    SELECT",[116,746,747],{"class":136}," 1",[116,749,750],{"class":122}," FROM",[116,752,191],{"class":136},[116,754,140],{"class":129},[116,756,37],{"class":136},[116,758,445],{"class":129},[116,760,761,764,766,768,770,772],{"class":118,"line":271},[116,762,763],{"class":122},"    WHERE",[116,765,420],{"class":136},[116,767,140],{"class":129},[116,769,53],{"class":136},[116,771,461],{"class":122},[116,773,774],{"class":129}," p_tenant\n",[116,776,777,780,782,784,786,788,790,792,795],{"class":118,"line":314},[116,778,779],{"class":122},"      AND",[116,781,420],{"class":136},[116,783,140],{"class":129},[116,785,458],{"class":136},[116,787,461],{"class":122},[116,789,629],{"class":136},[116,791,140],{"class":129},[116,793,794],{"class":136},"current_profile",[116,796,797],{"class":129},"()\n",[116,799,800,802,804,806,808,810,812],{"class":118,"line":321},[116,801,779],{"class":122},[116,803,420],{"class":136},[116,805,140],{"class":129},[116,807,486],{"class":136},[116,809,489],{"class":122},[116,811,386],{"class":122},[116,813,814],{"class":129},")\n",[116,816,817],{"class":118,"line":327},[116,818,573],{"class":129},[11,820,821,824,825,828,829,831,832,835],{},[15,822,823],{},"is_member_of"," runs as ",[15,826,827],{},"SECURITY DEFINER"," so that its query on ",[15,830,37],{}," does not have to pass that table's own RLS again. That avoids recursion and saves time. ",[15,833,834],{},"SET search_path = ''"," means everything must be fully qualified, and no other schema can slip in a function with the same name. For the functions the whole tenant separation depends on, that is not optional.",[11,837,838,839,842,843,846,847,850],{},"The real ",[15,840,841],{},"current_tenant()"," also falls back to ",[15,844,845],{},"app_metadata.tenant_id"," in the JWT after ",[15,848,849],{},"request.tenant_id",". That matters for realtime.",[20,852,854],{"id":853},"asymmetric-supabase-rls-read-the-active-book-write-the-rows-book","Asymmetric Supabase RLS: read the active book, write the row's book",[11,856,857],{},"Now the core. The policies for bookings first looked like this:",[107,859,861],{"className":109,"code":860,"language":111,"meta":112,"style":112},"CREATE POLICY booking_select ON public.booking FOR SELECT\n  USING (internal.is_current_tenant(tenant_id));\n\nCREATE POLICY booking_insert ON public.booking FOR INSERT\n  WITH CHECK (internal.can_write_booking(tenant_id));\n\nCREATE POLICY booking_update ON public.booking FOR UPDATE\n  USING (internal.can_write_booking(tenant_id))\n  WITH CHECK (internal.can_write_booking(tenant_id));\n",[15,862,863,889,907,911,933,952,956,978,993],{"__ignoreMap":112},[116,864,865,867,870,873,876,878,880,883,886],{"class":118,"line":119},[116,866,624],{"class":122},[116,868,869],{"class":122}," POLICY",[116,871,872],{"class":129}," booking_select ",[116,874,875],{"class":122},"ON",[116,877,191],{"class":136},[116,879,140],{"class":129},[116,881,882],{"class":136},"booking",[116,884,885],{"class":122}," FOR",[116,887,888],{"class":122}," SELECT\n",[116,890,891,894,897,899,901,904],{"class":118,"line":156},[116,892,893],{"class":122},"  USING",[116,895,896],{"class":129}," (",[116,898,613],{"class":136},[116,900,140],{"class":129},[116,902,903],{"class":136},"is_current_tenant",[116,905,906],{"class":129},"(tenant_id));\n",[116,908,909],{"class":118,"line":217},[116,910,318],{"emptyLinePlaceholder":317},[116,912,913,915,917,920,922,924,926,928,930],{"class":118,"line":232},[116,914,624],{"class":122},[116,916,869],{"class":122},[116,918,919],{"class":129}," booking_insert ",[116,921,875],{"class":122},[116,923,191],{"class":136},[116,925,140],{"class":129},[116,927,882],{"class":136},[116,929,885],{"class":122},[116,931,932],{"class":122}," INSERT\n",[116,934,935,938,941,943,945,947,950],{"class":118,"line":241},[116,936,937],{"class":122},"  WITH",[116,939,940],{"class":122}," CHECK",[116,942,896],{"class":129},[116,944,613],{"class":136},[116,946,140],{"class":129},[116,948,949],{"class":136},"can_write_booking",[116,951,906],{"class":129},[116,953,954],{"class":118,"line":247},[116,955,318],{"emptyLinePlaceholder":317},[116,957,958,960,962,965,967,969,971,973,975],{"class":118,"line":253},[116,959,624],{"class":122},[116,961,869],{"class":122},[116,963,964],{"class":129}," booking_update ",[116,966,875],{"class":122},[116,968,191],{"class":136},[116,970,140],{"class":129},[116,972,882],{"class":136},[116,974,885],{"class":122},[116,976,977],{"class":122}," UPDATE\n",[116,979,980,982,984,986,988,990],{"class":118,"line":259},[116,981,893],{"class":122},[116,983,896],{"class":129},[116,985,613],{"class":136},[116,987,140],{"class":129},[116,989,949],{"class":136},[116,991,992],{"class":129},"(tenant_id))\n",[116,994,995,997,999,1001,1003,1005,1007],{"class":118,"line":265},[116,996,937],{"class":122},[116,998,940],{"class":122},[116,1000,896],{"class":129},[116,1002,613],{"class":136},[116,1004,140],{"class":129},[116,1006,949],{"class":136},[116,1008,906],{"class":129},[11,1010,1011,1013],{},[15,1012,949],{}," checks membership of the row's book, not of the active book. Why two different rules?",[578,1015,1016,1027],{},[581,1017,1018,1022,1023,1026],{},[1019,1020,1021],"strong",{},"Narrow reads."," Lists, dashboards and totals always work within one book. ",[15,1024,1025],{},"tenant_id = \u003Cone value>"," maps straight onto an index, and even a query without a filter returns only the open book.",[581,1028,1029,1032,1033,1037],{},[1019,1030,1031],{},"Writes against the row."," The app writes offline into an outbox (",[63,1034,1036],{"href":1035},"/en/blog/offline-first-flutter-supabase","how that works","). Someone who records three bookings in book B, switches to book A and only then gets a signal would see three rejected bookings under an \"active book only\" rule. With the membership rule they go through.",[11,1039,1040,1041,1044,1045,1048],{},"Then there is the role. Day-to-day data – bookings, receipts, tags, the shopping list – can be written by any member. The book's structure – accounts, categories, savings goals, rules – only by owners and admins. A second function, ",[15,1042,1043],{},"can_write",", checks ",[15,1046,1047],{},"role_in(tenant_id) IN ('owner', 'admin')"," for that.",[11,1050,1051,1052,896,1055,1061,1062,1064],{},"It still doesn't work without the matching header. Postgres also applies the SELECT policy to rows returned by ",[15,1053,1054],{},"RETURNING",[63,1056,1060],{"href":1057,"rel":1058},"https://www.postgresql.org/docs/current/sql-createpolicy.html",[1059],"nofollow","CREATE POLICY in the Postgres docs","), and the app needs the returned row because it carries the server's timestamps. So every outbox entry carries the ID of its book and is sent with exactly that book as ",[15,1063,17],{},". The write rule decides whether the write is allowed; the header makes sure the response is readable.",[20,1066,1068],{"id":1067},"realtime-has-no-header","Realtime has no header",[11,1070,1071,1072,1075,1076,42,1078,1080,1081,1083],{},"The hook runs before every PostgREST request, but not for realtime. Supabase checks ",[15,1073,1074],{},"postgres_changes"," events against the SELECT policy using only the subscriber's JWT. Without ",[15,1077,849],{},[15,1079,841],{}," fell back to ",[15,1082,845],{}," – always the user's own book. Members of a shared book therefore got no events for it, and their phones only caught up on the next pull. I noticed in October 2026.",[11,1085,1086],{},"The fix is a function that returns something different depending on the path:",[107,1088,1090],{"className":109,"code":1089,"language":111,"meta":112,"style":112},"CREATE FUNCTION internal.readable_tenant_ids() RETURNS uuid[]\n  LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public, internal, pg_temp\nAS $$\n  SELECT CASE\n    -- REST: the hook set an active book\n    WHEN NULLIF(current_setting('request.tenant_id', true), '') IS NOT NULL\n      THEN ARRAY[internal.current_tenant()]\n    -- Realtime: every book the caller belongs to\n    ELSE ARRAY(SELECT tm.tenant_id FROM public.tenant_member tm\n               WHERE tm.profile_id = internal.current_profile()\n                 AND tm.deleted_at IS NULL)\n  END\n$$;\n\nCREATE POLICY booking_select ON public.booking FOR SELECT\n  USING (tenant_id = ANY ((SELECT internal.readable_tenant_ids())::uuid[]));\n",[15,1091,1092,1108,1129,1135,1142,1147,1168,1179,1184,1212,1233,1250,1255,1259,1263,1283],{"__ignoreMap":112},[116,1093,1094,1096,1098,1100,1103,1105],{"class":118,"line":119},[116,1095,624],{"class":122},[116,1097,187],{"class":122},[116,1099,629],{"class":190},[116,1101,1102],{"class":129},".readable_tenant_ids() ",[116,1104,635],{"class":122},[116,1106,1107],{"class":129}," uuid[]\n",[116,1109,1110,1112,1114,1116,1118,1120,1122,1124,1126],{"class":118,"line":156},[116,1111,643],{"class":122},[116,1113,646],{"class":122},[116,1115,649],{"class":129},[116,1117,211],{"class":122},[116,1119,715],{"class":129},[116,1121,133],{"class":122},[116,1123,223],{"class":129},[116,1125,226],{"class":122},[116,1127,1128],{"class":129}," public, internal, pg_temp\n",[116,1130,1131,1133],{"class":118,"line":217},[116,1132,235],{"class":122},[116,1134,238],{"class":129},[116,1136,1137,1139],{"class":118,"line":232},[116,1138,274],{"class":122},[116,1140,1141],{"class":122}," CASE\n",[116,1143,1144],{"class":118,"line":241},[116,1145,1146],{"class":392},"    -- REST: the hook set an active book\n",[116,1148,1149,1152,1154,1156,1158,1160,1162,1165],{"class":118,"line":247},[116,1150,1151],{"class":122},"    WHEN",[116,1153,335],{"class":136},[116,1155,338],{"class":129},[116,1157,518],{"class":149},[116,1159,677],{"class":129},[116,1161,358],{"class":149},[116,1163,1164],{"class":129},") ",[116,1166,1167],{"class":122},"IS NOT NULL\n",[116,1169,1170,1173,1176],{"class":118,"line":253},[116,1171,1172],{"class":122},"      THEN",[116,1174,1175],{"class":122}," ARRAY",[116,1177,1178],{"class":129},"[internal.current_tenant()]\n",[116,1180,1181],{"class":118,"line":259},[116,1182,1183],{"class":392},"    -- Realtime: every book the caller belongs to\n",[116,1185,1186,1189,1191,1194,1196,1198,1200,1202,1204,1206,1208,1210],{"class":118,"line":265},[116,1187,1188],{"class":122},"    ELSE",[116,1190,1175],{"class":122},[116,1192,1193],{"class":129},"(",[116,1195,668],{"class":122},[116,1197,420],{"class":136},[116,1199,140],{"class":129},[116,1201,53],{"class":136},[116,1203,750],{"class":122},[116,1205,191],{"class":136},[116,1207,140],{"class":129},[116,1209,37],{"class":136},[116,1211,445],{"class":129},[116,1213,1214,1217,1219,1221,1223,1225,1227,1229,1231],{"class":118,"line":271},[116,1215,1216],{"class":122},"               WHERE",[116,1218,420],{"class":136},[116,1220,140],{"class":129},[116,1222,458],{"class":136},[116,1224,461],{"class":122},[116,1226,629],{"class":136},[116,1228,140],{"class":129},[116,1230,794],{"class":136},[116,1232,797],{"class":129},[116,1234,1235,1238,1240,1242,1244,1246,1248],{"class":118,"line":314},[116,1236,1237],{"class":122},"                 AND",[116,1239,420],{"class":136},[116,1241,140],{"class":129},[116,1243,486],{"class":136},[116,1245,489],{"class":122},[116,1247,386],{"class":122},[116,1249,814],{"class":129},[116,1251,1252],{"class":118,"line":321},[116,1253,1254],{"class":122},"  END\n",[116,1256,1257],{"class":118,"line":327},[116,1258,573],{"class":129},[116,1260,1261],{"class":118,"line":364},[116,1262,318],{"emptyLinePlaceholder":317},[116,1264,1265,1267,1269,1271,1273,1275,1277,1279,1281],{"class":118,"line":379},[116,1266,624],{"class":122},[116,1268,869],{"class":122},[116,1270,872],{"class":129},[116,1272,875],{"class":122},[116,1274,191],{"class":136},[116,1276,140],{"class":129},[116,1278,882],{"class":136},[116,1280,885],{"class":122},[116,1282,888],{"class":122},[116,1284,1285,1287,1290,1292,1295,1297,1299,1301,1304],{"class":118,"line":396},[116,1286,893],{"class":122},[116,1288,1289],{"class":129}," (tenant_id ",[116,1291,226],{"class":122},[116,1293,1294],{"class":129}," ANY ((",[116,1296,668],{"class":122},[116,1298,629],{"class":136},[116,1300,140],{"class":129},[116,1302,1303],{"class":136},"readable_tenant_ids",[116,1305,1306],{"class":129},"())::uuid[]));\n",[11,1308,576],{},[578,1310,1311,1318,1335],{},[581,1312,1313,1314,1317],{},"The function is wrapped in ",[15,1315,1316],{},"(SELECT …)"," so Postgres evaluates it once per statement rather than once per row.",[581,1319,1320,1321,1324,1325,1328,1329,1332,1333,140],{},"The cast to ",[15,1322,1323],{},"uuid[]"," is not decoration. Without it, ",[15,1326,1327],{},"= ANY (SELECT …)"," is the subquery form and compares ",[15,1330,1331],{},"uuid"," with ",[15,1334,1323],{},[581,1336,1337,1338,1341,1342,1345],{},"The result is an array, not an ",[15,1339,1340],{},"OR",". A version with ",[15,1343,1344],{},"OR internal.is_member_of(tenant_id)"," would be shorter, but it takes the tenant condition out of the index. Every REST query without its own tenant filter – most of the web app's – would then scan every book sequentially.",[11,1347,1348,1349,1352,1353,1356],{},"The price: an ",[15,1350,1351],{},"ORDER BY"," on another column, such as the date, can no longer walk the ",[15,1354,1355],{},"(tenant_id, date)"," index in order and sorts the book's rows instead.",[11,1358,1359,1360,140],{},"REST stays limited to the header, and realtime sees every book the person belongs to. Which book the app listens to is set when it subscribes, with the filter ",[15,1361,1362],{},"tenant_id=eq.\u003Cactive book>",[20,1364,1366],{"id":1365},"limits","Limits",[578,1368,1369,1375],{},[581,1370,1371,1374],{},[1019,1372,1373],{},"I test the policies by hand."," Risky migrations come with SQL scripts that I run against the local database, but there are no automated RLS tests in CI.",[581,1376,1377,1380],{},[1019,1378,1379],{},"Every new table needs the same care."," A table without RLS is open through the API. The rule in the project: no table without policies, and every new realtime table gets the array policy, or members of shared books never hear about it.",[20,1382,1384],{"id":1383},"takeaways","Takeaways",[578,1386,1387,1390,1393,1401],{},[581,1388,1389],{},"Send the active tenant as a request header and check it against the membership on the server. The client asks, the server decides.",[581,1391,1392],{},"Limit reads to the active tenant and check writes against the row's tenant whenever writes can arrive late.",[581,1394,1395,1396,1398,1399,140],{},"Keep RLS helper functions in a schema outside the API, with ",[15,1397,834],{}," and, where needed, ",[15,1400,827],{},[581,1402,1403],{},"Realtime evaluates policies without the pre-request hook. Every policy has to hold from that angle too.",[1405,1406],"hr",{},[11,1408,1409,1410,1415,1416,140],{},"MoneyKoi is ",[63,1411,1414],{"href":1412,"rel":1413},"https://play.google.com/store/apps/details?id=com.psyren.moneykoi",[1059],"available as an Android app on Google Play",". More information at ",[63,1417,1420],{"href":1418,"rel":1419},"https://www.moneykoi.com",[1059],"moneykoi.com",[1422,1423,1424],"style",{},"html pre.shiki code .suJrU, html code.shiki .suJrU{--shiki-default:#FF7B72}html pre.shiki code .sZEs4, html code.shiki .sZEs4{--shiki-default:#E6EDF3}html pre.shiki code .sFSAA, html code.shiki .sFSAA{--shiki-default:#79C0FF}html pre.shiki code .s9uIt, html code.shiki .s9uIt{--shiki-default:#A5D6FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sc3cj, html code.shiki .sc3cj{--shiki-default:#D2A8FF}html pre.shiki code .sH3jZ, html code.shiki .sH3jZ{--shiki-default:#8B949E}",{"title":112,"searchDepth":156,"depth":156,"links":1426},[1427,1428,1429,1430,1431,1432,1433,1434],{"id":22,"depth":156,"text":23},{"id":79,"depth":156,"text":80},{"id":97,"depth":156,"text":98},{"id":606,"depth":156,"text":607},{"id":853,"depth":156,"text":854},{"id":1067,"depth":156,"text":1068},{"id":1365,"depth":156,"text":1366},{"id":1383,"depth":156,"text":1384},"/images/blog/supabase-rls-multi-tenant/cover.en.webp","2026-10-31T00:00:00.000Z","Supabase RLS for multi-tenant apps: how MoneyKoi checks the active tenant with a header and a pre-request hook, and secures reads and writes differently.","md","supabase-rls-multi-tenant",[1441,1442,1443,1444,1445],"Supabase RLS multi-tenant","Supabase row level security","Supabase multi tenancy","PostgREST db_pre_request","Supabase tenant header",{},"/blog/en/supabase-rls-multi-tenant",[1449],"moneykoi",{"title":6,"description":1437},"blog/en/supabase-rls-multi-tenant",[1453,1454,1455],"Supabase","PostgreSQL","Security",null,"tAHusH_UaW8KHiSysry6-dnjaYm1e5NE66MhXNvToSM",1569,[1460,1463],{"slug":1461,"title":1462,"minutes":271},"offline-first-flutter-supabase","Offline-first with Flutter and Supabase: an outbox, not spinners",{"slug":1464,"title":1465,"minutes":314},"receipt-scanner-flutter-ml-kit","A receipt scanner in Flutter: ML Kit, RKSV QR codes, no LLM",[1467,1469],{"code":1468,"slug":1439},"de",{"code":1470,"slug":1439},"en",1793537120015]