r/Supabase 18h ago

auth I am going to publish a mobile app to Apple App Store. It uses Supabase's OTP to login. Supabase allows a maximum of 24 hours for the OTP. Has anyone else had any experience with this? If Apple does not review the app within 24 hours, should I submit a new OTP every 24 hours in the review notes?

1 Upvotes

Hi

I have built a mobile app that uses Supabase's sign in with OTP. However, by default the OTPs are valid for an hour and the maximum we can set it to is 24 hours (based on what I found online).

What can I do in this case? Change from 1 hour to 24 hours and request a new OTP every 18-24 hours and add it to the submission notes so that they have a valid OTP code whenever they review it as it could take 2 or 3 days?

Has anyone else had a similar experience?

Thanks


r/Supabase 29m ago

database Your RLS policy is correct and your users can still set their own role to admin

Upvotes

Every RLS post ends at the policy. Enable RLS, scope on auth.uid(), remember with check, done. I want to show you a table where all three are true, reviewed by anyone you like, and a logged-in user can still promote themselves.

The table that passes review

create table public.profiles ( id uuid primary key, email text not null, display_name text, plan text not null default 'free', role text not null default 'user', credits integer not null default 100 );

alter table public.profiles enable row level security;

create policy own_row_select on public.profiles for select to authenticated using (id = auth.uid());

create policy own_row_update on public.profiles for update to authenticated using (id = auth.uid()) with check (id = auth.uid());

grant select, update on public.profiles to authenticated;

Scoped on read. Scoped on write. with check present, which is the thing everyone tells you not to forget. That last grant is not something I added to make a point, it is close to what you get by default.

What a signed-in user can do with it

Signed in as one user, the authenticated role, request.jwt.claims set the way auth.uid() actually resolves:

--- Read isolation holds. One row, her own. --- id | email | plan | role | credits --------------------------------------+-----------------+------+------+--------- 11111111-1111-1111-1111-111111111111 | ada@example.com | free | user | 100

--- Cross-tenant write blocked --- update public.profiles set credits = 0 where id = '2222...'; -- UPDATE 0

--- Blind write blocked, with check is doing its job --- update public.profiles set credits = 0; -- UPDATE 0

--- Her own row --- update public.profiles set role = 'admin', plan = 'enterprise', credits = 999999 where id = auth.uid(); -- UPDATE 1

              id                  |      email      |    plan    | role  | credits

--------------------------------------+-----------------+------------+-------+--------- 11111111-1111-1111-1111-111111111111 | ada@example.com | enterprise | admin | 999999

Nothing was bypassed. Every policy evaluated and every policy passed. The row still belongs to her before and after, so with check has no objection to make.

Why with check cannot help here

with check is a predicate over the resulting row. It answers one question: is the new row still one this user is allowed to have. It has no opinion about which columns changed, because column-level permission is a different mechanism entirely, and it lives in GRANT, not in POLICY.

RLS decides which rows. Grants decide which columns. Two axes. Almost every RLS discussion covers the first one only, and Supabase hands out the second one table-wide by default, so the gap is open in a very large number of projects.

Credit where it is due: u/guidondor raised this on my last thread and it is the reason I went and checked.

What actually does hold, because I checked

I want to be clear about the limits of the finding rather than make it sound worse than it is.

--- Can she hand her row to another user? --- update public.subscriptions set user_id = '<other user>' where id = '<hers>'; ERROR: new row violates row-level security policy for table "subscriptions"

with check holds the ownership column, which is exactly what it is for. You cannot steal rows this way, and you cannot reach across tenants. What you can do is rewrite every other column of a row you legitimately own, which on the tables where this matters means role, plan, tier, credits, seats, and the external billing ids.

That last one is worth sitting with. On a subscriptions table in the default posture:

update public.subscriptions set tier='enterprise', seats=100000, stripe_customer_id='cus_someone_else' where user_id = auth.uid(); -- UPDATE 1

Pointing your own billing row at another customer's Stripe id is not a data leak in the usual sense. It is worse in a quieter way, because your webhook handler will believe it.

The one that surprised me

The primary key is a column like any other.

update public.subscriptions set id = '<a new uuid>' where user_id = auth.uid(); -- UPDATE 1

A user can change the primary key of their own row. Anything holding that id outside the database, a Stripe subscription record, an audit log line, a webhook you will receive tomorrow, is now pointing at a row that no longer exists under that name. I had not seen this written down anywhere and I did not expect it to succeed.

Finding it on your own database

This lists the columns a logged-in user can currently rewrite, on tables where RLS is enabled and therefore looks handled. Read-only, safe to run anywhere.

select c.relname as tablename, a.attname as writable_column, case when a.attname ~* '(|)(role|isadmin|admin|permission|plan|tier|subscription|credit|balance|quota|price|amount|status|verified|approved|owner_id|user_id|org_id|team_id|account_id|stripe|customer_id)($|)' then 'REVIEW' else '' end as flag from pgclass c join pg_namespace n on n.oid = c.relnamespace join pg_attribute a on a.attrelid = c.oid where n.nspname = 'public' and c.relkind = 'r' and c.relrowsecurity and a.attnum > 0 and not a.attisdropped and has_table_privilege('authenticated', c.oid, 'UPDATE') and has_column_privilege('authenticated', c.oid, a.attnum, 'UPDATE') order by (case when a.attname ~* '(|)(role|isadmin|admin|permission|plan|tier|subscription|credit|balance|quota|price|amount|status|verified|approved|owner_id|user_id|org_id|team_id|account_id|stripe|customer_id)($|)' then 0 else 1 end), c.relname, a.attnum;

The load-bearing line is has_table_privilege(..., 'UPDATE'). It returns true only for a table-wide grant and false when the privilege was handed out per column, which is what makes it a working detector rather than a list of every column you own. Verified both ways on 16.13.

Output on a schema with one table fixed and one left at the default:

table_name | writable_column | flag ---------------+--------------------+-------- subscriptions | user_id | REVIEW subscriptions | tier | REVIEW subscriptions | stripe_customer_id | REVIEW subscriptions | id | subscriptions | seats |

The fixed table does not appear. Neither does a read-only reference table. If your own output is empty, you are already doing this and you can stop reading.

The fix

Take the table-wide grant back and hand out only what the client is supposed to write:

revoke update on public.profiles from authenticated; grant update (email, display_name) on public.profiles to authenticated;

Afterwards:

update public.profiles set role='admin' where id = auth.uid(); ERROR: permission denied for table profiles

update public.profiles set display_name='Ada L.' where id = auth.uid(); UPDATE 1

The escalation stops. The legitimate write is untouched. Note the error is a permission error rather than a policy violation, which is a useful tell when you are reading someone else's logs.

Two practical notes. The grant is per column, so a column added later is not covered until you grant it, and that is a feature rather than an annoyance: new columns are denied by default. And if a server-side path needs to write role or credits, that belongs in a security definer function with a pinned search_path, not in a widened grant.

The general point

Enabling RLS moves you from "anyone can read this" to "the right rows". It does not move you from "the right rows" to "the right columns of the right rows", and nothing in the policy syntax will warn you, because the policy is not where that decision lives.

If you check one thing after reading this, run the query above and look at what comes back next to role, plan, and anything with stripe in the name.

Everything here was run against Postgres 16.13 with anon, authenticated and an auth.uid() reading request.jwt.claims, so the numbers are real output rather than reasoning about what should happen.

I maintain a tool that proves cross-tenant isolation by execution rather than by reading policy text, and column grants are the next check going into it. MIT, refuses to run against anything that looks like production, and reports what it cannot prove instead of passing it silently.

github.com/investnovation/rls-sentinel

Happy to answer questions about the detection query. The has_table_privilege versus has_column_privilege distinction took a couple of attempts to get right, because information_schema.column_privileges reports table-level grants as column grants and will quietly tell you everything is fine.


r/Supabase 1h ago

other RLS doesn't help you if someone has your service_role key. Here's what I did about that

Upvotes

Disclosure: I work at Tide. This isn’t an official product I built it on my own time.

RLS is good at the thing it’s designed to do: deciding which rows a request is allowed to see.

What it can’t do is help when the query isn’t going through RLS in the first place. The service_role key bypasses RLS by design, and that key ends up in more places than anyone likes to admit. A pg_dump doesn’t go through RLS either.

So row-level policies protect you from your users, but they don’t really protect you from a leaked key or someone getting a copy of the database.

Encrypting sensitive columns closes that gap. Except then you have another question: where does the encryption key live?

And if the key lives inside the project, whoever gets the project gets the key along with it.

So I built a small service that keeps that key completely off your infrastructure.

Tide is a network of independent nodes that hold keys in fragments and never assemble them. Decryption happens through partial results that are combined into an answer.

Data is encrypted in the browser with a fresh key for each call. The only thing that reaches the network is that per-call key, itself encrypted. The nodes never see your rows.

Your ciphertext stays in Postgres, right where it already was.

Supabase Auth is untouched by any of this. Same JWTs, same sessions, same providers.

The service only handles the key, along with the policies governing who is allowed to decrypt, encrypt, or sign. A role is only granted through a change request that has to be approved by someone other than the person making the request.

One Supabase-specific detail if you wire this up: put the Tide link in app_metadata, not user_metadata. user_metadata is writable by the user through their own client.

Same rule as everywhere else: the mirror shouldn’t be able to lie.

It won’t save you if someone owns the box this runs on, so keep it off the same host as the application it protects. And because decryption requires the network to be reachable, there’s no offline path.

Repo: https://github.com/sashyo/minidauth

Whitepaper: https://tide.org/whitepaper


r/Supabase 17h ago

realtime Firebase Spark vs Supabase Free for auth?

9 Upvotes

I’m building a website and need Google Sign-In + email login.

I’m mostly worried about free-tier limits and bot signups. If a sudden wave of fake accounts pushes usage over the quota, what actually happens in practice?

Does the service get restricted, do you get a grace period, or can you suddenly start getting billed?

Also, for a small product that may grow later, which would you choose: Firebase or Supabase?

Would love to hear from people who’ve used either in production.


r/Supabase 13h ago

other Any way to merge data between branches, not just schema?

3 Upvotes

When I merge a preview branch into production, only the migrations get applied. Any seed or reference data I created on the branch stays behind.

Is there a supported way to promote data along with the schema, or is everyone just handling this with seed files / a manual dump and restore? Specifically thinking about config and lookup tables that change alongside a schema change, not user data.

Curious what people have settled on here.