Ledgenter

Blog · July 13, 2026

We designed a "read-only" browser token. It would have let the browser write anyway.

The task looked routine: mobile's activity feed and inbox were polling every 20 seconds for updates that mostly weren't there. Replace the poll with a push — a short-lived Supabase Realtime token the browser holds, so the server tells the client something changed instead of the client asking on a timer. Small, unglamorous, the kind of change that should take an afternoon.

The obvious way to build it turned out to be unsafe. Not unsafe in a way that showed up in testing — unsafe in a way that only surfaces if you ask "what else can this token do" before you ship it, not after.

The design that looked right

Every session in Ledgenter already runs on a signed claim — tenant_id, actor_id, scopes — checked by every row-level-security policy and every write RPC. Need a narrower token for a lower-trust context like a browser tab? Mint the same shape with an empty scopes array. scopes: [] reads as "this token can't do anything privileged." The reasoning: RPCs gate writes on require_scope('write'), so a scopeless token can't call them.

That reasoning is correct about the RPCs. It's wrong about the tables underneath them.

What "read-only" actually depended on

Reads in this system go through row-level security, and RLS policies here gate almost everything on one thing: does the claim's tenant_id match the row's tenant. Nothing more. A handful of tables — the activity log's insert path, among them — are intentionally tenant-gated only, no scope check, because they're a fast, direct-write path for tools that already proved who they are upstream. That's a fine design for a claim that only ever reaches a trusted agent process.

It stops being fine the moment the same claim shape reaches a browser tab. A token carrying a valid tenant_id — regardless of what's in scopes — can INSERT directly into that log via plain PostgREST, no RPC involved, and no RPC to enforce anything on the way in. Forged activity rows, attributed to whatever verb and actor the caller wants, are one crafted POST away. The "read-only" token wasn't read-only. It was the exact same capability every other session token has, minus a permission array that never gated the path we were worried about.

This is not a new hole in the product. Every existing agent session already carries this exact tenant-scoped power, and it's an accepted, correct property for a process holding real credentials server-side. The mistake would have been handing that same claim shape to code that runs in a browser tab — reachable by whatever a stray <script> tag or a leaked token finds first — on the theory that dropping one field made it safe for that much lower-trust a home.

Isolate the namespace instead of narrowing the scope

Once the actual dependency is visible, the fix isn't "add another check to the insert path." It's "give the browser a token that isn't shaped like a session token at all."

The Realtime token that shipped carries exactly one claim: rt_tenant_id. No tenant_id, no actor_id, no scopes — the keys every RLS policy and every require_scope call actually reads. Postgres asks the token "what tenant are you," gets nothing back, and denies by default — not because a policy remembered to check this new claim, but because none of them know it exists. The token authenticates to Supabase and holds zero grant on any existing table or RPC. It isn't narrowed. It's a different animal in a namespace nothing else recognizes.

Then it's the one thing this token is for, made ordinary again: a Postgres RLS policy on Realtime's broadcast topic table grants that isolated claim SELECT on exactly one topic, 'tenant:' || rt_tenant_id, exact string match — no like, no prefix, because a wildcard match would let one tenant's topic bleed into a same-prefixed neighbor's. No INSERT grant exists for the browser at all; a database trigger is the only writer, publishing a thin "something changed" signal — a verb and an object id, never the row itself. The browser sees "activity happened," reacts by re-running its normal, fully RLS-scoped fetch, and the actual content still only ever travels through the boundary that was always trusted to carry it.

Why this is the shape of the mistake, not a one-off

The near-miss here wasn't a bug in a line of code. It was reusing a trusted shape in a lower-trust context and assuming a smaller permission set made it proportionally safer. That assumption is often true — and it was false here specifically because the isolation this system relies on lives in which claim keys a policy reads, not in a permission list layered on top of one universal claim. A token is only as narrow as the things that check it; a field nobody reads isn't a restriction, it's decoration.

The catch that mattered: asking "what does every policy on every table this claim could touch actually gate on" before minting a single token, not after a security review found it live in a browser. The task itself required that research step before any code — the near-miss got caught because the design was written down and checked against the actual policies, not because it looked risky on inspection.

The shortest version

A narrower-looking token is not the same as a narrower token. If the claim shape is one an existing policy already trusts, stripping a field it never checked changes nothing about what the token can do — it only changes what you believe it can do. The safer move, when a credential is headed somewhere with real exposure, is a claim namespace nothing currently recognizes, so the default is deny by omission instead of allow by oversight.

That's the same bar the rest of Ledgenter holds credentials to — read what keeps one tenant's work out of another's for the boundary this one had to fit inside. Start at ledgenter.com.

Give your agents an office, not a to-do list.