Auth & access control
Lock down who can read and write your data (auth checks + row-level security).
Auth is two questions, and founders routinely answer only the first: who are you (authentication) and what are you allowed to touch (authorization). Skipping the second is how a logged-in user reads everyone else's rows.
Authentication: let the platform do it
Don't hand-roll password hashing, session tokens, or OAuth flows. Use Supabase Auth, Clerk, Auth.js, or your platform's built-in provider. They handle bcrypt/argon, secure cookies, refresh rotation, and email verification correctly โ things that are easy to get subtly wrong.
A few non-negotiables whatever you pick:
- Verify email before granting real access, or you'll drown in spam signups.
- Store sessions in httpOnly cookies, not
localStorage, so XSS can't read the token. - Never trust a user id sent from the client. Derive the current user from the verified session on the server, every request.
Authorization is where indie apps break
The trap: you check auth in your UI ("hide the delete button if not the owner") and call it done. But your API and database are still wide open to anyone who crafts the request directly. UI checks are cosmetics. Enforcement happens server-side.
Row-Level Security is the real lock
If you use Supabase, the database itself enforces access with RLS policies. This is the strongest pattern for a solo founder because it can't be bypassed by a forgotten API check โ the rule lives next to the data.
Enable it per table, then write policies:
alter table notes enable row level security;
-- users can read only their own notes
create policy "read own notes"
on notes for select
using (auth.uid() = user_id);
-- users can insert notes only as themselves
create policy "insert own notes"
on notes for insert
with check (auth.uid() = user_id);
The two clauses matter: using filters which rows are visible; with check validates rows being written. Forgetting with check lets a user insert a row owned by someone else.
The classic mistake: enabling RLS but writing no policy. That denies everyone, so you "fix" it by using the service_role key on the client to make things work โ which disables all security. If RLS is fighting you, write the policy; never ship the service key to the client.
Without RLS
On a custom backend, enforce ownership in every handler:
const note = await db.note.find(id);
if (note.userId !== session.userId) return res.status(403).end();
Do it in every read, update, and delete path. One missed handler is the whole hole.
Roles and the admin trap
Keep roles in the database (a role column or a memberships table), never inferred from the client. For an admin surface, check the role server-side on every action โ an "admin" route that only hides links in the nav is not protected.
Test it like an attacker
Open a second account. Try to read account #1's data by id. Try to PATCH a record you don't own. Hit your API routes directly with curl and no session. If anything succeeds, you have a hole โ and it's better you find it than a stranger.
Ship-ready when
- Auth handled by a real provider; email verified; sessions in httpOnly cookies.
- RLS enabled with explicit policies on every user-data table (or ownership checked in every handler).
-
service_rolenever reaches the client. - Roles live in the DB and are checked server-side.
- You've tried to read/modify another account's data and failed.
I'm building ProveMyApp so founders like you can connect your apps, track their growth with verified metrics, and build alongside other founders instead of doing it alone.
Explore the library
More guides and playbooks to grow your app