Building a banking app with React and Supabase Row Level Security.

ZimPay is a banking app — create an account, hold a balance, send money to another user instantly, and see every transaction in your history. It's a simulation, but I built it like the real thing, because the moment you're moving money between accounts you have to take correctness and access control seriously. The interesting story isn't the UI; it's Row Level Security.

The stack, and why Supabase

ZimPay is React 18 + TypeScript on the front end with React Router v6, built with Vite, styled with plain CSS custom properties (glassmorphism, with dark and light themes). The backend is Supabase — Postgres, authentication and realtime. For an app about money, the appeal of Supabase is that the security model lives in the database, not scattered across front-end checks you have to remember to write.

The one rule that matters: you can only touch your own money

In a banking app, the catastrophic bug is one user reading or moving another user's funds. The naive approach is to filter by user ID in your front-end queries — and that works right up until one query forgets to, or someone calls the API directly. The protection is in the wrong place.

Row Level Security puts it in the right place. RLS is a Postgres feature where the access rule lives on the table and is evaluated for every row against the currently authenticated user. A policy like "a user may only select rows where user_id = auth.uid()" means the database itself refuses to return anyone else's data — no matter what the client asks for.

With RLS on, a bug in my React code can't leak another person's balance. The database is the last line of defence, and it doesn't trust the front end.

Transfers have to be all-or-nothing

A transfer is two changes that must happen together: debit the sender, credit the recipient, and write the history. If the app crashed between those steps you could debit one account and never credit the other — money vanishing into a bug. The fix is to do the whole thing as a single atomic operation (a database transaction / function) so either all of it commits or none of it does. There is no in-between state where the books don't balance.

The everyday banking features

On top of that foundation, ZimPay does what you'd expect of a banking app:

  • Instant transfers — send money to another user and both balances update right away.
  • Find recipients — search by username or phone number rather than making people copy account numbers.
  • Transaction history — every sent and received payment, itemised.
  • Real account safety — email verification on sign-up, password reset by email link, and "keep me signed in" sessions.
  • Profile management — update your name and phone, see your balance and account details at a glance.

Why glassmorphism and themes were worth it

A money app has to feel trustworthy, and visual polish is part of that signal. The glassmorphism UI, smooth micro-interactions and proper dark/light themes aren't decoration for its own sake — they make the app feel like something you'd actually trust with a balance. Built on CSS custom properties, theming is just swapping a set of variables, so it cost very little to do well.

What I took away

  • Push security down to the database. RLS turns "remember to filter by user" from a discipline into a guarantee.
  • Make money moves atomic. One transaction for the whole transfer, every time.
  • Trust is a feature. Verification, history and polish are what make a balance feel safe — and they're cheap compared to the cost of getting them wrong.
Related
→ ZimPay project overview → Try the live demo → Building a ride-hailing app
Is Supabase good for building a banking or fintech app?

For a simulation or prototype, yes. You get Postgres, auth and realtime out of the box, and Row Level Security lets the database enforce that users only ever touch their own money — the most important rule in a banking app.

What is Row Level Security and why does it matter for money?

RLS is a Postgres feature where access rules live on the table and are evaluated per row against the logged-in user. It means even a front-end bug can't expose or move another user's balance — the database refuses.

How do you stop a transfer going wrong halfway?

Run the whole transfer in one database transaction or function — debit, credit and history together — so either all of it happens or none of it does. You never leave money debited from one account but not credited to the other.