---
title: "Supabase multi-tenant RLS: bruger i to orgs lækker data"
description: "En RLS-policy, der tjekker medlemskab, er sand i begge organisationer for en bruger, der tilhører to. Probens fund, rettelsen og et tjek på to minutter."
url: "https://madsalt.tech/da/notes/supabase-rls-user-in-two-organizations/"
author: "Denys Zaichenko"
language: "da"
published: 2026-10-18
---

# Supabase multi-tenant RLS: bruger i to orgs lækker data

TL;DR

En policy som `auth_in_org(organization_id)` spørger "er den, der kalder, medlem af den organisation, som rækken tilhører?". For en bruger, der tilhører organisation A og B, er svaret ja i begge. I min probe gav RLS alene sådan en bruger læse- og skriveadgang til et dokument i den anden organisation. Da jeg føjede den nuværende anmodnings organisation til forespørgslen, kom der 0 rækker tilbage.

Jeg fandt det, mens jeg gjorde [platformen til byggedokumenter](https://madsalt.tech/da/work/construction-document-platform/) mere robust, en multi-tenant Supabase-app. Hver tenant-tabel havde row level security slået til og gjort tvungen, så det så færdigt ud.

## Hvad så sikkert ud?

Alle policies havde samme form: rækkens `organization_id` skal være en organisation, som den, der kalder, er medlem af. Derfor slog nogle få server actions et dokument op alene på id. Tankegangen var "RLS filtrerer det alligevel".

```ts
const { data } = await supabase
  .from('documents')
  .select('*')
  .eq('document_id', id)
  .single()
```

For en bruger med ét medlemskab virker det, og en test med sådan en bruger består.

## Hvorfor lader RLS den anden organisation slippe igennem?

Policyen er en medlemskabstest. Postgres evaluerer den for hver række, som [dokumentationen om row security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) beskriver, og den ved ikke, hvilken organisation anmodningen handler som, fordi det bor i appen: i URL'en, en cookie eller organisationsskifteren. For en bruger, der er medlem af begge organisationer, består alle rækker i begge.

![Hvilken org en anmodning hører til, står ikke i policyen. Én bruger, medlem af org A og org B, arbejder i org A. RLS-policy alene: Kald i org A → Medlem af radens org? → Ja, A eller B → Org B-række vist; RLS plus denne anmodnings org: Kald i org A → Medlem af radens org? → Række i org A? → 0 rækker fra org B.](https://madsalt.tech/charts/da/supabase-rls-user-in-two-organizations.svg)

*Min probe på en oprettet bruger i to orgs, september 2026: RLS alene gav læse- og skriveadgang til org B's dokument; med anmodningens org kom der 0 rækker tilbage.*

Så `organization_id`-filteret i appen er ikke et ekstra lag oven på RLS. For denne bruger er det den eneste kontrol.

Når du alligevel er inde i de helpers, så tjek, om de ser på `organizations.is_active`. Medlemskabshelpers gør det ofte ikke, og så beholder en deaktiveret organisation læse- og skriveadgang i RLS-laget. Når det eneste `is_active`-tjek bor i appens tenant resolver, springer enhver forespørgsel, der går uden om resolveren, også deaktiveringen over.

## Hvad rettede det?

Anmodningens organisation blev et obligatorisk argument til hver servicefunktion, der læser eller skriver tenant-data. Et kaldested kan ikke glemme den, fordi funktionen ikke kompilerer uden.

```ts
export async function getDocument(orgId: string, id: string) {
  return supabase
    .from('documents')
    .select('*')
    .eq('organization_id', orgId)
    .eq('document_id', id)
    .single()
}
```

To regler mere kom ud af gennemgangen:

- Tag aldrig organisationen fra den række, du er ved at skrive. `update ... where id = $1 and organization_id = <samme rækkes egen organization_id>` er altid sand, så den beskytter ikke noget.
- En sammensat fremmednøgle på `(id, organization_id)` får databasen til at afvise en forbindelse mellem rækker i forskellige organisationer. Tjek hver underordnet tabel, det er let at tilføje den til tre og glemme den fjerde.

## Hvordan tjekker du din egen app?

Opret én bruger med et aktivt medlemskab i to organisationer. Optræd derefter som den bruger i SQL-editoren i en lokal kopi eller staging-kopi og bed om en række fra den anden organisation:

```sql
begin;
set local role authenticated;
set local request.jwt.claims = '{"sub": "<user id>", "role": "authenticated"}';

-- RLS alone: returns the org B document
select document_id from documents where document_id = '<org B document id>';

-- with the org of the request: 0 rows
select document_id from documents
where document_id = '<org B document id>'
  and organization_id = '<org A id>';
rollback;
```

Gentag det med en `update`, og endnu en gang efter at have sat den anden organisation til inaktiv. Hvis den første forespørgsel returnerer en række, så se på alle steder, der forespørger tenant-data alene på id.

## Hvor slemt er det?

Vurdér det ærligt. Angriberen skal have et ægte medlemskab i begge organisationer, så det handler om integritet og revisionsspor: en person med adgang til to kunder redigerer den forkerte, og loggen siger, at det var tilladt. Det er ikke en måde, hvorpå en fremmed kan læse alle tenants.

Den generelle lære er den fra [sådan tester jeg Supabase RLS](https://madsalt.tech/da/blog/how-to-test-supabase-rls/): test, hvad en rigtig bruger kan, med rigtige dataformer, ikke kun hvad hver policy siger. Tre relaterede noter: [en rolle taget fra tilmeldingsmetadata](https://madsalt.tech/da/notes/supabase-raw-user-meta-data-role/), [en NULL, der sprang et rettighedstjek over](https://madsalt.tech/da/notes/plpgsql-if-not-null-fails-open/) og [hvilke schemas Data API'et leverer](https://madsalt.tech/da/notes/supabase-exposed-schemas/). To-virksomheder-testen er det første punkt i min [sikkerhedstjekliste til Lovable-apps](https://madsalt.tech/da/blog/lovable-app-security-checklist/). Til policies, rettigheder og indstillinger omkring det kører det gratis [Supabase sikkerhedstjek](https://madsalt.tech/da/tools/supabase-security-check/) 46 OWASP Top 10:2025-regler; om en policy lader en bruger i to orgs slippe igennem, er et spørgsmål om dataform, som kun forespørgslen ovenfor besvarer.
