Skip to content

Build1 publisher3 min readPublished

Checking membership inside Postgres policies closes a 15.5-minute revocation gap in a no-backend app

Row-level security in a no-backend Neon app refused 27 of 27 tenant attacks, yet a removed member read on for 928 seconds. A membership check in the policies closed that gap at a cost of about 5 ms on function calls.

The Engineer · Build desk

Illustration accompanying Checking membership inside Postgres policies closes a 15.5-minute revocation gap in a no-backend app

What happened

  • Authors on dev.to built a multi-tenant task board from static files, Neon Auth and the Neon Data API, with no API server, serverless function or middleware.
  • Eve, a second team's owner, used a valid account and a script to try 27 attacks, including forged tokens, alg: none and upserts over foreign ids, and all 27 were refused.
  • A member removed from a team kept reading its data for 900 seconds plus 28, the life of the token they already held.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint Any policy that trusts only the signed organization claim ties how fast a removal takes effect to how long tokens live.
  • cost Closing the revocation window puts a membership lookup on every request, a cost each team has to measure on its own schema before trusting a task board's figure.
  • exposure A routine table-wide grant can reopen a policy bug that column grants were containing, so grant changes need the same attack suite as policy changes.
  • decision Dropping the API server moves authorization into SQL, and the proof that it holds becomes a suite that compares whole victim rows before and after each attack.

Nothing in the request path checks whether a membership still exists. Neon Auth writes the active team into the token's o claim, and it only does so for a team the user belongs to [4]. The Data API verifies the token on every request against Neon Auth's keys and runs the query as the authenticated role [12]. Inside Postgres, auth.organization_id() returns the claim, and every policy is org_id = auth.organization_id() [3]. A token signed before the removal still names the old team, and its signature still verifies [9]. After removal, the old token read, wrote and called the RPC, and it kept reading for its full 900 seconds plus 28 [9]. The total is 928 seconds, about 15.5 minutes [1]. The write-up does not explain the extra 28 seconds.

The policies as first written could not close that window. The authors' fix did. They added a check against Neon Auth's member table inside the policies, and access ended at once [10]. In their latency run, reads and writes stayed within a millisecond and the RPC got about 5 ms slower [10]. For a team board I would take that trade. Removal from a team is a security event, and I would not accept a quarter hour of read access after it. These figures come from one task board, though. For them to hold elsewhere, the membership lookup has to stay a cheap indexed read beside the query it guards. The RPC is where the check cost most, and it is the number I would re-measure on my own schema first.

I would copy the attack suite. Authorization lives in RLS policies, column grants and one function [2]. Eve, the second team's owner, attacked with a valid account and a script [5]. Most attackers start with less. Each of the 27 attempts had to return exactly the expected answer, and the suite compared every column of the victim's rows before and after [6]. A refusal that altered any victim data would have counted as a failure [6].

Then the authors tested the suite. They broke the security layer four ways, one at a time, and the suite caught each of the three breaks that let attacks through [7]. The fourth, a sloppy WITH CHECK (true), let nothing through on its own because the client is never granted the org_id column [8]. Adding table-wide grants on top of it opened the hole [8].

Those 27 refusals cover the 27 attacks the authors chose, among them forged tokens, alg: none and a token signed with Eve's own key [5]. An attack outside that list is untested.

Dropping the server did not add latency in their setup. A Data API request took 41 to 44 ms at p50, against 40 ms for a plain TCP connect to the same address [11]. The request adds 1 to 4 ms over the connect [2]. Network distance accounts for most of the total, and it will differ on your network.

What to watch

  • Whether Neon documents a membership check against its member table as the default pattern for Data API policies.
  • An independent attack suite run against the same two SQL files, testing cases outside the authors' 27.
  • Latency of membership-checked policies on tables and functions heavier than a task board's.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories