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.
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:
- Boilerplate hell: 50 routes × 20 lines of auth = 1,000 lines of repetitive code
- Authorization bugs: One missing
ifcheck = data leak - Performance: Each API call adds 50ms of network + auth overhead
- 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.