Back to Blog

Field-Level Security: Why Row-Level Isn't Enough

Mataki Labs · · 6 min read

If you’ve used Supabase or built on PostgREST, you’re familiar with row-level security (RLS). It’s the mechanism that controls which records a user can access — typically by filtering rows based on a policy expression.

RLS is essential. But it’s only half the picture.

Consider a contact table with fields: name, email, phone, budget, and status. Your enterprise customer has three roles: admin, manager, and viewer. Here’s what they need:

  • Admins see everything.
  • Managers see all fields except phone is partially masked.
  • Viewers see name and status only. No email, no phone, no budget.

RLS can’t express this. RLS filters rows — it decides whether a user can see record #42 at all. It cannot decide that a user can see record #42’s name but not record #42’s budget. That’s a column-level concern, and it requires field-level security (FLS).

The real-world consequences

Without FLS, teams end up in one of three places:

1. Application-layer field stripping

The most common approach. You fetch the full record from the database, then strip fields in your API layer before returning the response.

record = db.query("SELECT * FROM contacts WHERE id = ?", id)
if user.role == "viewer":
    del record["email"]
    del record["phone"]
    del record["budget"]
return record

This works until it doesn’t. It breaks when someone adds a new endpoint and forgets to strip. It breaks when a background job fetches contacts for a report and includes all fields. It breaks when the mobile app calls a different API that doesn’t apply the same rules. And it definitely breaks when an intern writes a CSV export that does SELECT *.

The fundamental problem: the security rule exists in application code, not in the data layer. Every code path that touches the data must independently enforce it. Miss one path and you have a data leak.

2. Multiple database views per role

You create a Postgres view for each role:

CREATE VIEW contacts_viewer AS
SELECT name, status FROM contacts;

CREATE VIEW contacts_manager AS
SELECT name, email, phone, budget, status FROM contacts;

This is more robust than application-layer stripping, but it has operational problems. Adding a field means updating every view. Adding a role means creating a new view and updating every query that references one. By the time you have 20 objects and 5 roles, you’re managing 100 views, and the complexity compounds every time your data model changes.

3. Just… not doing it

A surprising number of teams simply return all fields to all roles and handle visibility in the frontend. The API returns the full record, and the React component conditionally renders based on the user’s role.

This is not security. The data is in the network response. Anyone with browser dev tools can read it. This is “security through CSS.”

How FLS should work

In ToasterDB, field-level security is declared in the schema alongside the field definition:

"budget": {
  "type": "currency",
  "fls": {
    "admin": "read_write",
    "manager": "read",
    "viewer": "none"
  }
}

When a viewer queries contacts, the budget field is not stripped from the response after the fact — it’s never included in the SQL query in the first place. The engine reads the schema, determines which fields the current role can access, and generates a SELECT statement that only includes permitted columns.

This means:

  • No code paths to miss. Every query goes through the same engine. There’s no way to accidentally bypass FLS by calling a different endpoint.
  • No views to manage. The engine generates the correct projection dynamically based on the current role.
  • No frontend-only security. Fields with "none" access are never sent over the wire. They don’t exist in the response payload.

FLS + RLS = complete access control

The real power comes from combining FLS with RLS. Consider this schema:

"contact": {
  "properties": {
    "name": { "type": "string" },
    "email": {
      "type": "email",
      "fls": { "admin": "read_write", "support": "read", "viewer": "none" }
    },
    "budget": {
      "type": "currency",
      "fls": { "admin": "read_write", "finance": "read", "*": "none" }
    }
  },
  "rls": {
    "viewer": "self.owner_id == ctx.user_id"
  }
}

When a viewer queries contacts:

  1. RLS filters rows to only those owned by the current user.
  2. FLS removes email and budget from the projection.
  3. The viewer receives only their own contacts, with only name visible.

When an admin queries the same object:

  1. RLS is not applied (admin has no row-level restriction).
  2. FLS permits all fields.
  3. The admin receives all contacts with all fields.

Same query. Same endpoint. Different results based on role. The caller doesn’t need to know the rules — the engine enforces them.

Adding purpose-based access

FLS and RLS control who can see what based on role. But in privacy-sensitive applications, you also need to control access based on purpose. A support agent accessing a contact record for a support ticket has a different purpose than an analyst running an aggregate report.

ToasterDB’s privacy layer adds a purpose dimension to access control:

"email": {
  "type": "email",
  "privacy": { "pii_type": "email" },
  "purposes": ["support", "operations"],
  "masking": {
    "analytics": "email_domain",
    "default": "none"
  }
}

An analytics query with X-Purpose: analytics will see ***@acme.io instead of j.chen@acme.io. A support query with X-Purpose: support will see the full email. A query with no declared purpose, or a purpose not in the allowed list, gets nothing.

This is the level of access control that GDPR Article 5(1)(b) — “purpose limitation” — actually requires. Most teams implement it as a policy document that nobody reads. In ToasterDB, it’s enforced by the query engine.

The cost of getting this wrong

A missing WHERE tenant_id = ? clause leaks data between tenants. It’s a P1 incident, possibly a breach notification, possibly a lawsuit.

A missing FLS check is subtler. The data goes to the right tenant, but to the wrong person within that tenant. A junior salesperson sees compensation data. A customer support agent sees financial projections. An exported report includes PII that should have been masked.

These aren’t hypothetical scenarios. They’re the second-most-common class of data incident in multi-tenant SaaS (after credential compromise), and they’re almost always caused by application-layer security logic that was correct when written but incomplete as the codebase grew.

FLS enforced at the engine level eliminates this class of bug entirely. Not “reduces” — eliminates. If the schema says viewers can’t see budget, viewers cannot see budget. Period.


If you’re building multi-tenant SaaS and you’ve been implementing field visibility in application code, try ToasterDB’s free tier. Define FLS in your schema and watch the engine handle it. You might find that a significant chunk of your middleware becomes unnecessary.

Want to try ToasterDB?

Get Started Free