Sharing One Supabase Project Between Two Apps Safely
A second app inside an existing Supabase project: prefixed tables, RLS on with no policies, revoked grants, server-only access and an admin check for a shared user list.
The problem: one free project, two applications
A small app often does not justify a Supabase project of its own. rukon-link, my QR link page, lives in the same Supabase project as DriveKeep. Sharing is fine, but it changes two assumptions you can usually make: that every table in the schema is yours, and that every user in the auth system is one of your users. This article is how rukon-link handles both, for anyone adding a second app to an existing Supabase project.
Prefix everything the app owns
Every table and function rukon-link creates starts with link_: link_analytics_events, link_contact_requests, link_settings, link_daily_stats, link_dashboard() and link_purge(). Its indexes and scheduled job are prefixed the same way. The migration's first comment says why, so the reason travels with the schema.
A prefix is not security. It is ownership you can read: anyone browsing the table list can tell which app a table belongs to, a cleanup can be scoped to one app with confidence, and two apps cannot both create a table called settings. Every statement in the migration is also written to be re-runnable (if not exists, on conflict do nothing), which matters more when the database also holds someone else's data.
Deny by default: RLS on, no policies, grants revoked
rukon-link has no end-user accounts. Visitors never need to read these tables, and the only writes are page events and contact requests, which the server validates first. So browsers get no access at all:
alter table public.link_analytics_events enable row level security;
alter table public.link_contact_requests enable row level security;
alter table public.link_settings enable row level security;
alter table public.link_daily_stats enable row level security;
revoke all on public.link_analytics_events, public.link_contact_requests,
public.link_settings, public.link_daily_stats from anon, authenticated;
revoke all on function public.link_dashboard(date, date, text) from public, anon, authenticated;
revoke all on function public.link_purge() from public, anon, authenticated;
grant execute on function public.link_dashboard(date, date, text) to service_role;
grant execute on function public.link_purge() to service_role;Row Level Security enabled with no policies means the browser-facing roles see no rows and can write none. Revoking the table privileges as well is belt and braces: if someone later added a permissive policy by mistake, the role would still lack the privilege. Functions are executable by public by default in PostgreSQL, so they are revoked explicitly and granted only to the service role.
The Next.js server is the only client. It uses the service-role key, which bypasses Row Level Security, only in server code, and only after validating the request (for public writes) or confirming the admin (for reads). The key lives in a server-only environment variable and the module that uses it imports server-only.
Why a server key is right here and was wrong elsewhere
This is the opposite of what I did in ResearchForge, where the backend reaches the database as the signed-in user so that Row Level Security filters every query, and where connecting with a privileged key was the defect, described in Every Policy Was Correct, and Every Policy Was Inert. The difference is who the data belongs to. ResearchForge stores each user's private library, so per-user policies are the guarantee. rukon-link has no user-owned rows at all: every row is the site owner's data, written by the server on visitors' behalf. Deny-all to browsers plus a validated server path is the accurate model of that.
The risk moves accordingly. With a server key, a bug in a route handler is not caught by the database. That is why each public route validates every field and the tables repeat the important limits as CHECK constraints.
The auth user list is shared too
Supabase Auth belongs to the project, not to an app. In a shared project, "the user is signed in" only proves they have an account in that project, which could have been created for any app that uses the project. So rukon-link never treats a session as authorisation. Admin access requires the signed-in user's email to equal one configured ADMIN_EMAIL and to be confirmed, and every admin page, server action and API route checks it, verifying the token with Supabase rather than trusting the cookie. The admin account was created by hand in the Supabase dashboard; there is no sign-up form in rukon-link at all.
Two smaller things follow from sharing. Both apps' tables are visible to anyone with dashboard access to the project, so the boundary between the apps is organisational, not a security boundary. And a migration for one app runs against a database the other app depends on, which is one more reason to keep each migration additive and prefixed.
For a personal link page that trade was worth it. For two apps with different owners or different sensitivity, separate projects would be the right call. The project details are on the rukon-link project page.
The work behind this
- rukon-link case study A link page for a QR code that shares social links openly and contact details only on request.
- DriveKeep case study Log a refill from two photos, and never mistake a guess for a measurement.
- LinkNest case study Multi-tenant digital profile SaaS with privacy-preserving contact requests and WhatsApp presets.
Related
Keep reading
Refreshing a Supabase Session in Next.js 16's proxy.ts, and Not Authorising There
Next.js 16 renamed Middleware to Proxy. Using proxy.ts only to refresh a Supabase session cookie for admin routes, while every page and API route checks authorisation itself.
Every Policy Was Correct, and Every Policy Was Inert
A data-isolation defect that passed the entire test suite, shipped, and behaved perfectly. What it took to find it, and why the fix was to stop asking the application to be careful.
The Wrong Supabase Key Doesn't Fail. It Returns 200 and Zero Rows.
A publishable Supabase key in a server slot meant for a secret one: every read succeeded with no rows, the dashboard showed confident zeros, and the health check said all was well.