---
title: "RLS beskytter raden, ikke rettigheten"
date: 2026-06-03
description: "En budsjettapp la hele tilstanden – inkludert abonnementsplanen – i én Supabase-tabell med RLS. RLS stoppet IDOR, men ikke at jeg ga meg selv gratis Familie-plan."
tags: ["supabase", "rls", "pentest", "nextjs", "dmarc"]
url: https://erik.no/blogg/2026-06-03-rls-beskytter-raden-ikke-rettigheten
---

<Callout type="info">
  **FIKTIVT:** Alle firmanavn, personnavn, domener og e‑postadresser i dette
  innlegget er oppdiktet. Eventuell likhet med ekte virksomheter eller
  personer er tilfeldig. Detaljer fra reelle oppdrag er sanert.
</Callout>

Row-Level Security er lett å stole blindt på. Den svarer presist på ett
spørsmål — «er dette din rad?» — og når svaret er nei, får du ingenting. Men
på et nylig oppdrag holdt RLS helt tett mot IDOR, samtidig som den lot meg gi
meg selv en betalt funksjon gratis. Forskjellen ligger i hva RLS faktisk
håndhever, og hva utviklerne *trodde* den håndhevet.

## Kontekst

Oppdraget var en webapplikasjonstest av en personlig budsjettapp hos
Lommerusk AS (`lommeruskas.no`). Stacken var Next.js på Vercel med Supabase som
backend og Stripe for betaling. Produktsjefen, Kari Berg
(`kari.berg@lommeruskas.no`), ville særlig vite to ting: kunne en bruker se
andres økonomi, og holdt betalingsmuren?

Det første jeg kartla var datamodellen, og den var uvanlig konsekvent: så godt
som *hele* applikasjonstilstanden — transaksjoner, gjeld, sparing, budsjett,
husholdningsprofiler og feltet `subscriptionPlan` — lå i én JSONB-kolonne i
tabellen `user_app_state`, nøklet på `user_id`. Nettleseren skrev rett til
Supabase REST-API. Det betyr at RLS i praksis var den eneste server-kontrollen
mellom klienten og dataene.

## RLS gjorde jobben sin

### Probe-script mot tabeller og RPC-er

Jeg lot AI-en skrive et lite probe-script som gikk gjennom et knippe sannsynlige
tabell- og RPC-navn med en autentisert testbruker, og samtidig sjekket
cross-tenant-tilgang på `user_app_state`. Resultatet var ryddig: alle andre
tabellnavn ga `404` (ikke eksponert), og mot en *annen* brukers rad var det helt
tett.

```http
GET  /rest/v1/user_app_state?user_id=eq.<annen-bruker>   ->  200, Content-Range: */0
POST /rest/v1/user_app_state    (user_id = <annen-bruker>) ->  403  new row violates row-level security policy
```

### To testkontoer bekreftet at policyen holdt

Jeg bekreftet det med to reelle testkontoer: konto B kunne verken lese eller
skrive konto A sin rad. Policyen var akkurat som den skal være:

```sql
create policy "own rows" on user_app_state
  for all
  using      (auth.uid() = user_id)
  with check (auth.uid() = user_id);
```

Ingen IDOR. Hadde det vært hele historien, ville rapporten vært kjedelig.

## …men raden inneholdt rettigheter

### Planfeltet lå i klient-skrivbar tilstand

Det som gjorde meg nysgjerrig var at `subscriptionPlan` lå i den samme
klient-skrivbare blob-en. Jeg ba AI-en fange den faktiske skrive-forespørselen
appen sendte når jeg la til et element, og der lå hele tilstanden — inkludert
plan og profiler — i kroppen. Da ble spørsmålet åpenbart: hva skjer hvis jeg
bare endrer den?

### Plannavnet var beskyttet

Planen *navnet* var det ingenting å hente på. Skrev jeg `subscriptionPlan: "family"`,
ble det avstemt tilbake mot Stripe ved neste innlasting. Server-autoritativt, helt
riktig.

### Men profillisten var det ikke

Men funksjonen «legg til person i husholdningen» — en betalt Familie-funksjon —
var bare sperret av en dialogboks i frontend. Selve dataene, listen med profiler,
lå i `state.profiles`. Og det er *min egen* rad. RLS sier ja.

```js
// state.profiles er en del av min egen rad — RLS slipper skrivingen gjennom
state.profiles.push({ id: "p2", name: "Ekstra person" });
await supabase.from("user_app_state").upsert({ user_id: me, state });
// -> 200. Etter reload: to fullverdige profiler, på en plan som betaler for én.
```

Etter innlasting på nytt sto det to profiler i velgeren, jeg kunne bytte mellom
dem, og hver hadde sitt eget budsjett og sine egne transaksjoner. Familie-
funksjonalitet, på Solo-pris. Klient-tilstandslageret eksponerte til og med en
`addProfile()`-metode direkte i minnet — frontenden var aldri ment å være en
sikkerhetsgrense, men her var den den eneste.

<Severity level="medium" /> — ikke et datainnbrudd, men et brudd på
betalings-/rettighetsintegriteten. En bruker med gratis prøveperiode kunne skaffe
seg den dyre planens funksjoner uten å betale.

## Hvorfor det skjer

RLS er *radautorisasjon*. Den svarer på «eier du denne raden?», ikke på «har du
betalt for denne funksjonen?». Når rettighetsbærende data — plannivå, antall
profiler, feature-flagg — ligger i en rad brukeren selv eier og kan skrive til,
er det eneste serveren håndhever eierskapet. Forretningsregelen «Solo = én
profil» finnes da bare i frontend, og frontend er klientens domene.

Det lumske her er at teamet hadde gjort den vanskelige tingen riktig: de avstemte
plan-*navnet* mot Stripe. Det føltes nok som at betalingen var server-autoritativ.
Men de avstemte bare etiketten, ikke *grensene* etiketten skulle innebære.

## Og så det kjedelige funnet

### Manglende SPF og DMARC

Mens den interessante delen handlet om RLS og rettigheter, fant et helt
rutinemessig DNS-oppslag noe langt enklere. Jeg ba AI-en sjekke e-postoppsettet
på domenene i scope, og `lommeruskas.no` hadde verken SPF eller DMARC.

### Spoof-testen og hvor den landet

For å vise hva det betyr i praksis, sendte jeg en tydelig merket test-melding med
forfalsket avsender (`@lommeruskas.no`) fra en server som ikke er autorisert for
domenet, til en innboks jeg selv kontrollerte. Den ble akseptert og levert.

```text
MAIL FROM:<spoof-poc@lommeruskas.no>
RCPT TO:<min-egen-innboks>
... 250 2.6.0 Queued
```

Plasseringen var det interessante: hos en Microsoft 365-leietaker havnet den rett
i innboksen, mens forbruker-Outlook la den i søppelpost. Uten SPF/DMARC er det
mottakerens egne heuristikker som avgjør — og de er ikke til å stole på.
Meldingen var merket som en autorisert test, og gikk bare til en adresse jeg
hadde lov til å sende til; poenget var å vise eksponeringen, ikke å lure noen.

Kontrasten er hele poenget: det ene funnet krevde at jeg forsto en utradisjonell
datamodell; det andre var et manglende DNS-felt hvem som helst kunne sjekket på
ett minutt. Begge gikk i samme rapport.

## AI i loopen

Det meste av legwork-en her var AI-drevet: enumereringen av tabeller og RPC-er,
probe-scriptene for cross-tenant-tilgang, fangsten av den ekte forespørselen, og
de første forsøkene på å skrive en fremmed `user_id` (som ga `403` — bra for dem).
Jeg ba om en ting av gangen og leste resultatene.

Det maskinen ikke gjorde, var å se *hvorfor* `subscriptionPlan` i en skrivbar blob
var den interessante tråden, skille det reelle funnet fra støy, holde testingen
innenfor scope, og lande alvorlighetsgrad og anbefaling. Den dømmekraften —
og ansvaret for den — er fortsatt min. Arbeidsmåten er rask; den erstatter ikke at
noen faktisk forstår hva som er et funn.

## Hva jeg lærte

- **RLS er ikke rettighetshåndheving.** Den beskytter rader, ikke forretningsregler.
- **Ikke lagre rettighetsbærende felter i klient-skrivbar lagring.** Plannivå,
  kvoter og feature-flagg hører hjemme et sted brukeren ikke kan skrive til.
- **Å avstemme etiketten holder ikke** hvis grensene etiketten innebærer ikke
  håndheves i samme lag.
- En frontend-sperre er UX, ikke en kontroll. Hvis den eneste som stopper en
  handling er en dialogboks, finnes ikke kontrollen.
- De avanserte funnene stjeler oppmerksomheten, men sjekk alltid det kjedelige
  først: SPF, DMARC, sikkerhetsheadere. Det tar minutter og mangler oftere enn
  du tror.

## Veien videre

Den konkrete anbefalingen var å flytte rettighetsavgjørelsene ned i et lag
brukeren ikke styrer: en server-handling eller edge-funksjon som validerer
profilantall og funksjonsbruk mot det verifiserte abonnementet før noe lagres,
eventuelt en database-constraint som binder antall profiler til faktisk plan.
Dataene kan gjerne fortsatt ligge i Supabase — men skrivingen som *betyr* noe for
penger og rettigheter må gå gjennom noe som kan si nei.
