Almost every iOS app I start needs the same first hour of work: email sign-in, a place to keep per-user data, and a way to make sure users can only touch their own rows. Supabase gives you most of it. The part people get wrong is the profiles table, because the obvious version is also the insecure one.

This post walks through the setup I use: the SQL, the Swift, and the three mistakes to avoid. It assumes you know basic SwiftUI and have a free Supabase project.

The model: two tables, one id

Supabase Auth stores credentials in auth.users. You don't add your own columns there. Instead you create a public.profiles table whose primary key is the auth user's id:

create table if not exists public.profiles ( id uuid primary key references auth.users (id) on delete cascade, email text, full_name text, avatar_url text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); 

on delete cascade matters: when the auth user is deleted, the profile goes with it. No orphans.

Mistake 1: forgetting that your key ships inside the app

The key in your app is public. Anyone can extract it. So the key is not your security. Row Level Security (RLS) is.

alter table public.profiles enable row level security; -- signed-out clients get nothing revoke all on public.profiles from anon; create policy "Users can read their own profile" on public.profiles for select to authenticated using ((select auth.uid()) = id); create policy "Users can update their own profile" on public.profiles for update to authenticated using ((select auth.uid()) = id) with check ((select auth.uid()) = id); 

With RLS on and no matching policy, a request just sees zero rows. Wrapping auth.uid() in (select ...) lets Postgres evaluate it once per query instead of once per row.

Mistake 2: letting users update any column

A row-level policy says which rows a user can change. It says nothing about which columns. If you later add a subscription_status column, the policy above would let a user set it to active from the client.

Fix it with a column-level grant:

revoke all on public.profiles from authenticated; grant select on public.profiles to authenticated; grant update (full_name, avatar_url) on public.profiles to authenticated; 

Now the app can edit exactly two columns. Anything else (billing state, roles, flags) can only be written by your backend with the service role. When you add a user-editable column, you add it to that list on purpose.

Mistake 3: creating the profile from the app

If the app inserts the profile row after sign-up, you'll eventually have a user with no profile: the network dropped, or the app was killed between the two calls. Let the database do it, atomically, with a trigger:

create or replace function public.handle_new_user() returns trigger language plpgsql security definer set search_path = '' as $$ begin insert into public.profiles (id, email, full_name) values ( new.id, new.email, coalesce( nullif(new.raw_user_meta_data ->> 'full_name', ''), nullif(new.raw_user_meta_data ->> 'name', '') ) ) on conflict (id) do nothing; return new; end; $$; create trigger on_auth_user_created after insert on auth.users for each row execute function public.handle_new_user(); revoke execute on function public.handle_new_user() from public, anon, authenticated; 

Three details worth copying:

  • security definer lets the function write to profiles even though no user is signed in yet. Because it's powerful, set search_path = '' stops anyone from hijacking name resolution.
  • The last line removes the ability to call the function through the API. It should only fire from the trigger.
  • Metadata passed at sign-up (full_name) is copied into the profile in the same transaction.

Run all of this in SQL Editor > New query. Every statement above can be re-run safely if you add if not exists / drop ... if exists guards.

Turn on email sign-in

In the dashboard, go to Authentication > Sign In / Providers > Email. For production, keep Confirm email on. While developing you can turn it off, but know what changes: with confirmation on, signUp returns no session until the user taps the link.

The Swift side

Add supabase-swift with Swift Package Manager, then create one client:

import Supabase let client = SupabaseClient( supabaseURL: URL(string: "https://YOUR-PROJECT.supabase.co")!, supabaseKey: "YOUR_PUBLISHABLE_KEY" ) 

Use the publishable key (sb_publishable_...) from Connect or Settings > API Keys. Never put the secret key in an app.

Sign-in is one line:

try await client.auth.signIn(email: email, password: password) 

Sign-up carries the name along and tells Supabase where to send the confirmation link:

let response = try await client.auth.signUp( email: email, password: password, data: ["full_name": .string(fullName)], // becomes profiles.full_name redirectTo: URL(string: "myapp://auth-callback")! ) // nil session means "Confirm email" is on: show "check your inbox" let needsConfirmation = response.session == nil 

Then read the profile the trigger created:

let profile: Profile = try await client .from("profiles") .select() .eq("id", value: user.id) .single() .execute() .value 

One gotcha: PostgREST does not convert key case, so a fullName property needs a CodingKeys entry mapping to "full_name".

Restoring the session on launch

You don't have to store tokens yourself. The SDK keeps the session in the Keychain and replays it through authStateChanges:

for await (event, session) in client.auth.authStateChanges { switch event { case .initialSession, .signedIn: if let session { await load(session.user) } case .signedOut: // clear local state default: break } } 

Handle initialSession too, not only signedIn; it's the one that fires on a cold launch.

Confirmation links must open your app

The email link needs to land in the app, not a web page. Two steps:

  1. Register a URL scheme (Info tab > URL Types), e.g. myapp.
  2. Add myapp://auth-callback to Authentication > URL Configuration > Redirect URLs in Supabase.

Then finish the flow in SwiftUI:

.onOpenURL { url in Task { try await client.auth.session(from: url) } } 

Expired or reused links throw, so catch the error and tell the user instead of failing silently.

Test it like an attacker

Before you ship, sign in as a normal user and try to break your own rules:

// should fail: billing column is not in the update grant try await client.from("profiles") .update(["subscription_status": "active"]) .eq("id", value: user.id) .execute() // should return zero rows: someone else's profile let rows: [Profile] = try await client.from("profiles") .select().eq("id", value: someoneElsesId).execute().value 

If either succeeds, your policies are wrong. It takes two minutes and it's the best security check you'll do all week.

Where this leaves you

You now have sign-in, a profile that's created atomically, a table users can read and edit only as far as you intend, and sessions that survive relaunch. That is the foundation under every other feature.

I got tired of rebuilding this for each app, so I packaged it, along with Sign in with Apple, account deletion, a paywall, and a demo mode that runs before you connect a backend, as TruKit, a SwiftUI + Supabase starter kit. The details are at [truguidz.com] or (robertlong3.gumroad.com) if you'd rather skip the setup. Everything in this post works without it.

What's the one thing you wish you'd locked down earlier in your first Supabase project?