PRAiGO ยท architecture notes

We shipped a mobile app with no application server.

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.

41.5k
lines of Dart
22
Postgres tables
22
with row-level security
39
authorisation policies
498
database tests
0
servers to operate

01The shape

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.

The usual way
๐Ÿ“ฑ Client
renders, validates
โ†’
๐Ÿ–ฅ๏ธ API server
business logic, auth
you build it, you run it
โ†’
๐Ÿ—„๏ธ Database
stores rows
What we did
๐Ÿ“ฑ Client
renders
โ†’
API server
doesn't exist
โ†’
๐Ÿ—„๏ธ Database
stores rows and
enforces every rule

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.

02What a request actually goes through

1
Signed request arrives
A verified token identifies the caller. No token, no further steps.
2
Is the caller even allowed to call this?
Execute permission is revoked from everyone by default and granted deliberately, function by function. An un-granted procedure doesn't return "forbidden" โ€” it doesn't exist.
3
Row-level security decides which rows exist
Policies are evaluated per row. A denied read returns zero rows, not an error โ€” which is worth internalising, because an authorisation bug looks like an empty screen, not a crash.
4
Privileged functions check themselves
A 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.
5
File access derives from row access
A stored object is readable only if a visible database row references it. Remove the row and the file becomes unreachable without touching the bucket.

03The honest tradeoff

What it bought

  • Two people covered a real surface area. No middle tier meant roughly half the code to write and none to operate.
  • Rules can't be bypassed by a client bug, because the client was never the one enforcing them.
  • Authorisation became testable. It's data and SQL, so a test suite can attack it directly.
  • Nothing to patch, scale or page someone about.

What it cost

  • One place to get it wrong. Every rule lives in one system, so a mistake there is a data-exposure bug, not a feature bug.
  • A large trusted surface. Dozens of privileged functions, each individually responsible for its own authorisation check.
  • Deployment is migration-shaped. Shipping logic means altering a live database. There is no "roll back the server".
  • Client and schema version independently. An installed app can call a procedure the database hasn't been taught yet โ€” a failure mode you have to design around.

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.

04Decisions I'd repeat

Intent and reality are different tables

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.

The rewards ledger is append-only, and refuses to lie

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.

Idempotence enforced by the schema, not by guard code

"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.

Some tables have security enabled and zero policies

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.

Identity comes from the token, never from a parameter

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".

Deleting an account anonymises rather than erases

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.

05Testing that tries to break in

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.

The trap worth knowing about

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.

06Stack

ClientFlutter โ€” one codebase, iOS and Android
StateRiverpod, behind repository interfaces with mock implementations so the widget suite runs with no database
BackendPostgres with PostGIS for proximity search โ€” reached over auto-generated REST and RPC
AuthorisationRow-level security, plus deliberate execute grants per function
ServerlessA handful of small functions, only for the things a database genuinely can't do: sending mail, deleting stored files
SchedulingIn-database cron for lifecycle sweeps
RealtimeDeliberately narrow โ€” two subscriptions, not a firehose

07Would I do it again

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.