NewHuxly MCP — Connect Claude, Cursor & Codex.Learn more
How to Connect Supabase to a Mobile App
Back to Blog
GuideAug 28, 202612 min read

How to Connect Supabase to a Mobile App

Contents

Last updated: August 2026.

A mobile app can connect directly to Supabase for authentication, database queries, realtime updates, and file storage, but the client must use a publishable key and every exposed table needs intentional access rules.

The connection itself takes a few lines. The important work is designing ownership, Row Level Security, session storage, and server-only actions before real user data reaches the app.

Key Takeaways

- Use the project URL and publishable key in the mobile client. - Never place a Supabase secret key or legacy service role key inside the app. - Enable and test Row Level Security for every exposed table. - Store user identity in dedicated ownership columns such as user_id. - Put privileged operations, payment webhooks, and private API keys in backend functions.

Decide what Supabase will handle

Supabase combines several backend services around a Postgres database.

NeedSupabase service
User accounts and sessionsAuth
Structured application dataPostgres Database
Direct client queriesData API
Images and filesStorage
Live record changesRealtime
Privileged or third-party logicEdge Functions
Scheduled database workPostgres functions and supported scheduling tools

A simple task app might use Auth, a tasks table, and Row Level Security. A marketplace may also need Storage, Realtime, server-side payment logic, moderation, and private admin tools.

Start with the services required by the core journey. Extra backend features create more access rules and failure states to test.

Create a Supabase project and client credentials

Create a project in the Supabase dashboard, then open the API settings.

The mobile client needs:

  • Project URL
  • Publishable key

Older projects may show a legacy anon key. Supabase's current API key documentation explains that publishable keys are designed for public components, including mobile applications whose compiled packages can be inspected.

Secret keys and legacy service_role keys bypass Row Level Security. They belong only in a backend you control.

Security rule:

A mobile environment variable is packaging, not secret storage. Anything shipped inside the app can be extracted. The client key must be safe to expose because database policies limit what it can do.

Supabase's Expo React Native quickstart is a useful starting point, but its production notes still require reviewing policies and deployment credentials before launch.

Install and configure the Expo client

For an Expo or React Native project, install the Supabase client, a URL polyfill, and persistent storage:

bash
npx expo install @supabase/supabase-js react-native-url-polyfill @react-native-async-storage/async-storage

Add public environment variables:

env
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key

Create one shared client:

ts
import 'react-native-url-polyfill/auto'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createClient, processLock } from '@supabase/supabase-js'

const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL!
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY!

export const supabase = createClient(supabaseUrl, supabaseKey, {
  auth: {
    storage: AsyncStorage,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false,
    lock: processLock,
  },
})

Keep the client in one module and import it where needed. Several clients can create duplicate auth listeners and confusing session behavior.

Variable names differ by framework. Flutter and SwiftUI use their own configuration systems, but the security model stays the same: the app receives only public client credentials.

Model user-owned data explicitly

Suppose each signed-in user owns tasks.

Create a table with a direct reference to the authenticated user:

sql
create table public.tasks (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  title text not null check (char_length(title) between 1 and 200),
  completed boolean not null default false,
  created_at timestamptz not null default now()
);

The user_id column gives authorization policies a stable ownership field. Do not infer ownership from an email, display name, or value sent without verification.

Add indexes for fields used often in filters and ordering:

sql
create index tasks_user_id_created_at_idx
on public.tasks (user_id, created_at desc);

Keep data types strict. Use timestamps for time, numbers for amounts, booleans for true or false state, and foreign keys for relationships. A pile of text columns is easy to generate and hard to validate later.

Enable Row Level Security before querying from the app

Supabase's Row Level Security guide says exposed tables need RLS and intentional grants and policies. Policies act like automatic conditions on database operations.

Enable RLS:

sql
alter table public.tasks enable row level security;

Allow users to read only their own tasks:

sql
create policy "Users can read their tasks"
on public.tasks
for select
to authenticated
using ((select auth.uid()) = user_id);

Allow inserts only when the new row belongs to the signed-in user:

sql
create policy "Users can create their tasks"
on public.tasks
for insert
to authenticated
with check ((select auth.uid()) = user_id);

Allow updates without transferring ownership:

sql
create policy "Users can update their tasks"
on public.tasks
for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);

Allow deletion of owned rows:

sql
create policy "Users can delete their tasks"
on public.tasks
for delete
to authenticated
using ((select auth.uid()) = user_id);

Policy names are documentation. Give them language that explains the allowed action.

Test with two user accounts. User A should never be able to select, update, or delete User B's task, even with a manually changed request.

Add authentication and session handling

A basic email and password sign-up call looks like this:

ts
const { data, error } = await supabase.auth.signUp({
  email,
  password,
})

if (error) {
  throw error
}

Sign in:

ts
const { data, error } =
  await supabase.auth.signInWithPassword({
    email,
    password,
  })

if (error) {
  throw error
}

At app startup, restore the existing session before deciding which navigation tree to show:

ts
const {
  data: { session },
} = await supabase.auth.getSession()

Subscribe to changes so sign-in, sign-out, and refresh update the interface:

ts
const {
  data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
  setSession(session)
})

return () => subscription.unsubscribe()

Keep a session-loading state. Sending users to the sign-in screen before storage finishes loading creates a visible flash and may trigger redirect loops.

Supabase Auth uses JSON Web Tokens and integrates with RLS through the current user's identity. The official Supabase Auth documentation explains that relationship. For the mobile interface and protected navigation around it, see Huxly's authentication guide.

Read and write data from the mobile app

After sign-in, fetch the user's tasks:

ts
const { data, error } = await supabase
  .from('tasks')
  .select('id, title, completed, created_at')
  .order('created_at', { ascending: false })

if (error) {
  throw error
}

The policy limits the rows. You can still add an explicit user_id filter for query clarity or performance, but it does not replace RLS.

Insert a task with the authenticated user ID:

ts
const {
  data: { user },
} = await supabase.auth.getUser()

if (!user) {
  throw new Error('Sign in required')
}

const { data, error } = await supabase
  .from('tasks')
  .insert({
    user_id: user.id,
    title: title.trim(),
  })
  .select()
  .single()

if (error) {
  throw error
}

Update by stable ID:

ts
const { error } = await supabase
  .from('tasks')
  .update({ completed: true })
  .eq('id', taskId)

RLS still verifies ownership. The client cannot gain access by replacing taskId with another user's record.

Handle the returned error. Do not show a success state before the backend confirms the write unless the interface can roll back an optimistic update.

Keep privileged work on the server

Some actions should not run directly from the mobile app:

  • Stripe or payment provider webhooks
  • Creating admin users
  • Using secret API keys
  • Moderation with private credentials
  • Sending transactional email
  • Granting paid entitlements
  • Cross-user administrative queries
  • Expensive AI calls that need quotas
  • Signing private download links under custom rules

Supabase Edge Functions run server-side TypeScript and can handle webhooks or third-party integrations. Store secret credentials in function secrets, validate the caller where required, and return only the data the app needs.

A function should not trust a user_id from the request body for authorization. Verify the user's token and derive identity from the authenticated request.

Supabase's new secret keys and legacy service role keys bypass RLS. The key migration guide states that secret keys must stay in controlled backend environments and out of client code.

Use Storage without making files public by accident

Supabase Storage supports public and private buckets.

Use a public bucket only when every file inside is intended to be publicly readable, such as public product images. Use a private bucket for receipts, identity documents, user uploads, paid assets, and personal photos.

The Storage access control guide uses RLS policies on storage objects. Design file paths around ownership, such as:

text
user-id/profile/avatar.jpg
user-id/documents/document-id.jpg

Then write policies that compare the authenticated user with the ownership path or related database record.

Also set:

  • Allowed content types
  • Maximum file size
  • Upload naming rules
  • Replacement behavior
  • Retention and deletion rules
  • Signed URL duration for private downloads

Validate files on the backend when content affects safety or billing. A file extension sent by the client is not proof of its content.

Add realtime only where it improves the product

Realtime is useful for chat, collaborative status, live orders, and shared dashboards. It is unnecessary for data that changes only when the current user edits it.

A subscription adds lifecycle and duplication concerns:

  • Subscribe after the user and target are known.
  • Filter events to the smallest useful scope.
  • Unsubscribe when the screen or account changes.
  • Merge events by record ID.
  • Handle reconnects and missed updates.
  • Prevent a local write and realtime echo from creating duplicates.

Start with normal queries and explicit refresh. Add realtime when the user experience needs immediate remote changes.

Handle errors as product states

Supabase calls can fail because of validation, authorization, connection, rate limits, or server behavior.

FailureUser experience
No sessionAsk the user to sign in
RLS rejectionDo not expose policy details; offer a valid next step
OfflinePreserve input and allow retry
Duplicate valueExplain which value already exists
Upload too largeShow the allowed limit before retry
Session expiredRefresh or return safely to sign-in
Database timeoutKeep work and retry without duplication
Partial operationRecheck server state before repeating

Log enough context to diagnose the request, but never log passwords, tokens, secret keys, or private user content.

Test security with separate accounts

Before release, create users A and B and run this matrix:

TestExpected result
A selects A's recordAllowed
A selects B's record IDNo record returned or denied
A inserts with B's user IDDenied
A updates B's recordDenied
A deletes B's recordDenied
Signed-out client queries private tableDenied
Mobile client uses a secret keyMust never occur
Private file opened without authorizationDenied
Edge Function called without required tokenDenied

Also test expired sessions, deleted accounts, revoked access, and users switching accounts on one device.

Supabase documents testing Edge Functions with unit tests for business logic and integration tests for HTTP, authentication, database access, and related behavior.

Prepare the connection for production

Before publishing:

  1. Confirm the app contains only the project URL and publishable client key.
  2. Enable RLS for every exposed table and review grants.
  3. Test each policy with separate authenticated users.
  4. Move private credentials and privileged actions to backend functions.
  5. Configure production redirect URLs for email and social authentication.
  6. Use private Storage buckets for private user content.
  7. Add database indexes for frequent filters.
  8. Set backup, retention, and deletion procedures.
  9. Monitor authentication, database, storage, and function errors.
  10. Install a release build and test against the production project.

Keep development and production data separate. A test script that deletes sample records should never point to the production project.

Build your Supabase-powered app with Huxly

Huxly can help you build a mobile app with Supabase-style authentication, database tables, storage, secure backend logic, and the frontend connected to real data. Build in Expo, Flutter, or SwiftUI, preview and refine the workflow, then prepare the app for TestFlight or Google Play from the same project.

FAQ

Is the Supabase publishable key safe inside a mobile app?

Yes, it is designed for public clients, including mobile apps. It does not secure the database by itself. Row Level Security, grants, and backend authorization must limit what the key and each user can access.

Should I put the Supabase secret key in an environment variable?

Only in a controlled backend environment. A variable embedded in a mobile build can be extracted, so secret keys and legacy service role keys must never ship in the app.

Do I need my own backend server with Supabase?

Not for every feature. The mobile app can access Supabase Auth, Database, Storage, and Realtime under RLS. Use Edge Functions or another backend for secrets, webhooks, privileged actions, and private third-party APIs.

Why does my query return an empty array after enabling RLS?

The signed-in role may not have a matching select policy, the row may have the wrong owner ID, or the session may not be available. Test the current user, row ownership, grants, and policy expression instead of disabling RLS.

Can I use Supabase with Flutter or SwiftUI?

Yes. Supabase provides client libraries and official quickstarts for several platforms, including Expo React Native, Flutter, and iOS with SwiftUI. The same rules apply to public keys, sessions, RLS, Storage access, and backend secrets.

Conclusion

Connecting a mobile app to Supabase is easy. Connecting it safely requires clear ownership and policies.

Use public client credentials in the app, keep privileged keys on the server, and test every data rule with separate users. Once authentication, RLS, storage access, and failure states are working together, the app has a backend it can rely on rather than a connection that only works in a demo.