postgres rls security

The Death of the Backend API

Why we build apps that talk directly to Postgres using Row-Level Security instead of writing Express.js middleware.

February 18, 2026 — min read

Core Concept

Every startup builds the same backend:

app.post('/api/posts', authenticateUser, async (req, res) => {
  // Check if user owns this post
  const post = await db.posts.findOne({ id: req.body.postId });
  if (post.userId !== req.user.id) {
    return res.status(403).json({ error: 'Unauthorized' });
  }
  // Update the post
  await db.posts.update({ id: req.body.postId }, req.body);
  res.json({ success: true });
});

This pattern repeats 50+ times per app. Every endpoint is 70% authorization logic, 30% business logic.

The question: Can we make the database enforce authorization instead?

The Constraint

Traditional backend problems:

  1. Boilerplate hell: 50 routes × 20 lines of auth = 1,000 lines of repetitive code
  2. Authorization bugs: One missing if check = data leak
  3. Performance: Each API call adds 50ms of network + auth overhead
  4. Coupling: Frontend can’t query directly (must wait for backend team)

Example security breach:

// Oops, forgot to check ownership
app.delete('/api/posts/:id', async (req, res) => {
  await db.posts.delete({ id: req.params.id });
  // ❌ BUG: Any user can delete any post!
});

This bug has caused billion-dollar breaches (looking at you, Facebook Cambridge Analytica).

The Solution

Postgres Row-Level Security (RLS): Authorization in the database.

How It Works:

-- Enable RLS on the table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- Policy: Users can only read their own posts
CREATE POLICY "Users see their own posts"
ON posts FOR SELECT
USING (auth.uid() = user_id);

-- Policy: Users can only update their own posts
CREATE POLICY "Users update their own posts"
ON posts FOR UPDATE
USING (auth.uid() = user_id);

Now your frontend can query Postgres directly:

// Flutter/React code
const { data } = await supabase
  .from('posts')
  .select('*')
  .eq('id', postId);

// ✅ Postgres automatically enforces: auth.uid() = user_id
// ❌ If user doesn't own post, query returns empty

No backend API needed.

The Architecture

Old way:

Flutter App → Express API → Postgres
           (auth middleware)

New way:

Flutter App → Supabase (Postgres with RLS)
           (auth enforced by database)

What gets eliminated:

  • 1,000+ lines of Express middleware
  • JWT validation on every request
  • Authorization bugs (database enforces it)
  • Backend deployment (one less service to manage)

The Example: Lifework

Lifework (veteran network) has complex permissions:

  • Users can see public profiles
  • Users can see their own private data
  • Admins can see everything
  • Veterans can see other veterans (but not civilians)

RLS policies:

-- Public profiles
CREATE POLICY "Public profiles are visible"
ON profiles FOR SELECT
USING (is_public = true);

-- Own data
CREATE POLICY "Users see their own data"
ON profiles FOR ALL
USING (auth.uid() = user_id);

-- Admin access
CREATE POLICY "Admins see everything"
ON profiles FOR ALL
USING (
  auth.jwt() ->> 'role' = 'admin'
);

-- Veteran-only content
CREATE POLICY "Veterans see veteran content"
ON posts FOR SELECT
USING (
  posts.visibility = 'veterans_only' 
  AND EXISTS (
    SELECT 1 FROM profiles 
    WHERE profiles.user_id = auth.uid() 
    AND profiles.veteran_verified = true
  )
);

Result: Zero backend code. All authorization logic lives in Postgres.

The Trade-Off

What you lose:

  • Complex business logic (can’t do file uploads, payments in RLS)
  • Backend observability (no logs of who queried what)
  • Fine-grained rate limiting (database can’t throttle per-user)

What you gain:

  • 1,000+ fewer lines of code
  • Zero authorization bugs (database guarantees it)
  • Faster queries (no network hop to backend)
  • Easier auditing (RLS policies are declarative)

When NOT to Use RLS

Don’t use RLS for:

Payment processing: Needs backend validation (Stripe webhooks)
Email sending: Needs backend (SMTP, templates)
File uploads: Needs backend (virus scanning, resizing)
Complex workflows: Needs backend (multi-step transactions)

Do use RLS for:

CRUD operations: 90% of your app
User permissions: Who can see/edit what
Multi-tenant systems: Isolate customer data
Audit logging: Database triggers + RLS = automatic audit trail

The Performance Win

Traditional API:

User → [100ms network] → API → [10ms query] → [100ms network] → User
Total: 210ms

Direct database access:

User → [100ms network] → Postgres (with RLS) → [100ms network] → User
Total: 200ms

Savings: 10ms per query doesn’t sound like much. But:

  • Dashboard with 20 queries: 200ms saved
  • Multiplied by 10K requests/hour: Massive latency improvement
  • Better UX = higher conversion

The First Principle: The best code is no code. If the database can enforce rules, don’t write backend logic.