Two people, one Flutter client, one Postgres database, and nothing in between. Every authorisation rule lives in the database. Here's what that bought us, what it cost, and the handful of decisions I'd make the same way again.
The conventional mobile stack has three tiers: the app, an API server you write and run, and a database behind it. The middle tier is where most of the code usually lives โ and most of the operational cost.
Business logic lives in SECURITY DEFINER Postgres functions. Authorisation lives in
row-level security policies. The client calls remote procedures and renders what comes back. The
public API key ships inside the app binary and that is fine โ because the database, not the app,
is the thing enforcing access.
The client is a rendering layer. If you can compromise it, you still can't read a row you weren't entitled to.
SECURITY DEFINER function runs as the owner, so policies are bypassed inside it. Each one re-derives the caller's identity and authorises independently. This is the largest trusted surface in the system and it's treated that way.That last one is the sharpest edge, and it's the reason a release checklist matters more here than in a three-tier stack. It isn't a flaw in the pattern; it's the bill the pattern sends you.
Saying you'll attend and actually attending are stored separately, and the rewards system only ever reads the second one.
It would have been easy to add a status column and a rule saying "don't award points until status is attended." Instead the schema makes the wrong thing unrepresentable. Rules you can forget to check are worse than shapes that can't express the mistake.
Every points event is an immutable row; a balance is their sum. Nothing is ever edited.
A penalty can push a balance negative, and we explicitly declined to clamp it at zero. Clamping would have made one screen look tidier and quietly broken the invariant that the ledger is the truth. Reconciling a number against an audit trail is only possible while the audit trail is still the authority.
"Award this once" is a partial unique index, not an if not exists check.
Guard code is one refactor away from being bypassed by a second code path. A constraint is enforced no matter who writes the insert, including a future maintainer who never read the original function.
Configuration and pending verification codes are sealed: RLS on, no policy grants access. Nothing holding a user token can read them at all โ they're reachable only through privileged functions.
The instinct is to write a narrow read policy. Writing none is stronger, and it makes the intent legible: this table is not part of the client's world.
The routine that erases a user's content takes a user id as an argument โ and is therefore callable only by trusted server code, never by a client.
The one component allowed to call it derives that id from the caller's own verified token and never from the request body. It's a small discipline that separates "delete my data" from "delete anyone's data".
Leaving removes the person โ name, photos, files โ while preserving the shared record that a group of people were somewhere together.
A hard delete cascades, which means one person leaving would rewrite other people's history. Getting this right before launch rather than after a complaint was worth the extra day.
498 database assertions, and the useful ones don't check configuration โ they attempt the attack:
can an anonymous caller read a profile? โ must return nothing
can a guest cancel an event they don't host? โ must be refused
can a host award themselves the attendance bonus? โ must be refused
can a member of one group read another's chat? โ must return nothing
Asserting that a policy exists proves you wrote configuration. Asserting that an attack fails proves the configuration does what you believed. Those are very different claims, and only the second one survives a refactor.
Row-level security policies are SQL, and SQL has scoping rules that don't announce themselves. An unqualified column reference inside a policy subquery can bind to the subquery's table rather than the row being filtered โ at which point the predicate quietly compares a column to itself and is always true. It still parses. It still passes a "does the policy exist" check. It grants everything.
We found one and fixed it by qualifying the outer reference. What actually found it was a test that tried to read a neighbour's data and expected nothing back. Qualify every column in a policy predicate, and write the test from the attacker's side.
| Client | Flutter โ one codebase, iOS and Android |
| State | Riverpod, behind repository interfaces with mock implementations so the widget suite runs with no database |
| Backend | Postgres with PostGIS for proximity search โ reached over auto-generated REST and RPC |
| Authorisation | Row-level security, plus deliberate execute grants per function |
| Serverless | A handful of small functions, only for the things a database genuinely can't do: sending mail, deleting stored files |
| Scheduling | In-database cron for lifecycle sweeps |
| Realtime | Deliberately narrow โ two subscriptions, not a firehose |
For a small team building something CRUD-shaped with real multi-tenant authorisation: yes, without much hesitation. The leverage is enormous and the security posture ends up better than a hand-rolled middle tier usually achieves, because the enforcement lives next to the data instead of in whichever handler remembered to check.
The pattern stops fitting when you need to orchestrate third-party services, run long or expensive jobs, or express logic that reads badly in SQL. When that day comes the answer isn't to retreat โ it's to add a service for that specific job and leave authorisation where it already works.
What I'd tell anyone starting down this road: write the adversarial tests first. Everything good about this architecture depends on the authorisation layer being correct, and the only way to believe it's correct is to keep trying to break it and keep failing.