Mobile Development
FlutterSupabaseAuthenticationRealtimeOffline-firstRLSMobile DevelopmentPostgreSQL

Flutter + Supabase in Production: Auth, Realtime, RLS, and Offline-Friendly Data Access (2026 Guide)

AO
Adrijan Omićević
·15 min read

# What You’ll Build (and Why It Matters)#

This guide shows how to ship Flutter apps with Supabase in production, focusing on the four areas that usually break after launch: authentication flows, secure data access with Row Level Security, realtime updates, and offline-friendly data access.

If you’re deciding between Firebase and Supabase, read our Flutter Firebase tutorial first to understand what Firebase gives you out of the box, especially offline sync, then use this guide to implement similar UX on Supabase.

You’ll also see how to structure the code so auth, data, and sync concerns don’t leak across your codebase. For architecture options, reference Flutter app architecture: clean architecture, feature-first.

# Prerequisites#

RequirementVersionNotes
Flutter3.19+Stable recommended
Dart3.3+Comes with Flutter
Supabase projectAnyEnable Email auth and Realtime
supabase_flutterLatestOfficial client for Flutter
Local storagedrift or isarFor offline cache and outbox
Secure storageflutter_secure_storageFor tokens and sensitive flags

ℹ️ Note: Supabase Auth sessions are persisted by supabase_flutter. Still, you should understand where and how tokens are stored, and how to handle session refresh, logout, and multi-device edge cases.

# 1) Data Model That Survives Production#

Before you write Flutter code, lock down a schema that supports RLS and offline sync. A typical “tasks” example is enough to prove the patterns.

You want these columns almost every time:

  • id UUID primary key
  • user_id UUID owner
  • created_at, updated_at
  • deleted_at nullable timestamp for soft delete
  • version integer for conflict detection
  • client_updated_at timestamp set by the client, used for reconciliation

In PostgreSQL terms:

ColumnTypeWhy
iduuidStable ID for offline-created records
user_iduuidRLS ownership checks
updated_attimestamptzServer-side source of truth for ordering
client_updated_attimestamptzHelps when offline edits happen across devices
versionintDetect lost updates, support merge strategies
deleted_attimestamptz nullableTombstones for sync and realtime

💡 Tip: Prefer UUIDs generated client-side for offline creates. You can generate them in Flutter and insert later, so the UI can navigate to details screens without waiting for the network.

# 2) Supabase Auth in Flutter: Flows That Don’t Bite Later#

Production auth is mostly about edge cases: session refresh, deep links, multiple providers, and “half logged-in” states.

2.1 Installing and initializing Supabase#

Dart
// main.dart
import 'package:flutter/material.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
 
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
 
  await Supabase.initialize(
    url: const String.fromEnvironment('SUPABASE_URL'),
    anonKey: const String.fromEnvironment('SUPABASE_ANON_KEY'),
  );
 
  runApp(const MyApp());
}
 
final supabase = Supabase.instance.client;

Avoid hardcoding keys. Use --dart-define per environment.

Bash
flutter run \
  --dart-define=SUPABASE_URL=your-url \
  --dart-define=SUPABASE_ANON_KEY=your-anon-key

2.2 Email and password (with production UX)#

In production, the minimum is:

  • validate email format and password length client-side
  • show clear error messages for rate limit and invalid credentials
  • confirm email if required
  • support password reset
Dart
Future<void> signInWithEmail(String email, String password) async {
  final res = await supabase.auth.signInWithPassword(
    email: email,
    password: password,
  );
  if (res.session == null) {
    throw Exception('No session returned');
  }
}

For sign up:

Dart
Future<void> signUp(String email, String password) async {
  await supabase.auth.signUp(
    email: email,
    password: password,
    data: {'marketing_opt_in': false},
  );
}

If you support Google or Apple, test deep links on:

  • Android: multiple browsers, custom tabs
  • iOS: Safari and in-app browser
  • cold start and warm start

Make sure your redirect URLs are correctly configured in Supabase and your app.

⚠️ Warning: OAuth “works on my device” often fails in production because of incorrect redirect URLs, missing SHA-256 fingerprint for Android, or Universal Links not matching the production bundle ID. Test a release build early.

2.4 Session lifecycle: listen once, route correctly#

You need a single source of truth for auth state.

Dart
Stream<AuthState> authStateChanges() {
  return supabase.auth.onAuthStateChange.map((e) => e);
}

Routing logic should distinguish between:

  • signed out
  • signed in
  • token refreshing
  • password recovery or email confirmation flows

For production, implement “soft logout” on auth errors: clear local cache only when you’re sure the user is signed out, not when a transient network error occurs.

Tie this back to clean architecture so UI doesn’t call Supabase directly. For a feature-first approach, follow our Flutter app architecture guide.

LayerResponsibilityShould know Supabase client
UIForms, routing, view stateNo
AuthRepositorySign-in/out, session streamYes
Use cases“SignIn”, “SignOut” orchestrationNo
Data sourcesSupabase API callsYes

# 3) Secure Data Access with RLS: The Non-Negotiable Part#

RLS is why Supabase is production-grade for mobile. Without it, anyone can query your database by copying your anon key.

3.1 Enable RLS and deny by default#

For each table exposed to the client:

  1. 1
    enable RLS
  2. 2
    create explicit policies for select, insert, update, delete

In SQL:

SQL
alter table public.tasks enable row level security;
 
-- Read only your own tasks
create policy "tasks_select_own"
on public.tasks for select
using (auth.uid() = user_id);
 
-- Insert only with your user_id
create policy "tasks_insert_own"
on public.tasks for insert
with check (auth.uid() = user_id);
 
-- Update only your own
create policy "tasks_update_own"
on public.tasks for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
 
-- Soft delete only your own
create policy "tasks_delete_own"
on public.tasks for delete
using (auth.uid() = user_id);

3.2 Avoid trusting client-provided user_id#

Even with RLS, you should reduce footguns. Use a database default for user_id where possible.

SQL
alter table public.tasks
alter column user_id set default auth.uid();

Now the client doesn’t need to send user_id on insert, and you remove an entire class of mistakes.

🎯 Key Takeaway: Your Flutter app is an untrusted client. Enforce ownership and permissions in PostgreSQL, not in Dart.

3.3 Multi-tenant data: teams and memberships#

If your app has teams, you’ll need membership tables and join-based policies.

A common layout:

TableKey columnsPurpose
teamsid, created_byTeam entity
team_membersteam_id, user_id, roleMembership and role
tasksid, team_id, created_byTeam-scoped content

Policy idea: allow access if user is a member of the team.

SQL
create policy "tasks_select_team_member"
on public.tasks for select
using (
  exists (
    select 1
    from public.team_members m
    where m.team_id = tasks.team_id
      and m.user_id = auth.uid()
  )
);

3.4 Common RLS gotchas#

  1. 1

    Forgetting with check on insert and update
    using controls which rows are visible or targetable, while with check controls what new data is allowed.

  2. 2

    Using security definer functions incorrectly
    If you create RPC functions, understand whether they bypass RLS. Keep them minimal and audited.

  3. 3

    Realtime and RLS mismatch
    Realtime events can appear “missing” if the user isn’t allowed to see the row under RLS. That’s correct behavior, but it looks like a bug during testing.

# 4) Realtime in Flutter: Subscriptions That Scale#

Supabase Realtime is great for collaborative updates, live dashboards, and chat-like experiences. It can also destroy performance if you subscribe too broadly.

4.1 Subscribe narrowly and only when needed#

Example: listen to tasks for a single team.

Dart
RealtimeChannel subscribeToTasks(String teamId, void Function(Map<String, dynamic>) onChange) {
  final channel = supabase.channel('tasks:$teamId');
 
  channel.onPostgresChanges(
    event: PostgresChangeEvent.all,
    schema: 'public',
    table: 'tasks',
    filter: PostgresChangeFilter(
      type: PostgresChangeFilterType.eq,
      column: 'team_id',
      value: teamId,
    ),
    callback: (payload) => onChange(payload.newRecord),
  );
 
  channel.subscribe();
  return channel;
}

Lifecycle rule: subscribe on screen enter, unsubscribe on dispose.

4.2 Realtime + local cache: treat realtime as invalidation#

If you’re offline-first, realtime should not be your primary data source. Use it to invalidate or patch your local cache.

A practical rule:

  • if you have the row locally, merge the payload
  • if you don’t, schedule a background fetch for that page or query

4.3 Throttle UI updates#

If you subscribe to high-frequency changes, don’t rebuild the entire list for every event. Batch updates for 100 to 300 ms, then apply a single state update.

⚠️ Warning: Subscribing to “all rows in a table” is the fastest way to create battery drain, excessive rebuilds, and hard-to-debug data leaks if your filters are wrong.

# 5) Offline-Friendly Data Access: What Supabase Doesn’t Do For You#

Firebase’s killer feature for many apps is built-in offline persistence and conflict resolution. Supabase is PostgreSQL-first, so you need to implement offline UX intentionally.

For conflict strategies and real-world examples, read Flutter offline-first sync conflict resolution.

5.1 Target UX: “instant UI” with eventual consistency#

A realistic offline-first target looks like this:

  • reads are served from local DB instantly
  • writes update local DB immediately and enqueue an outbox event
  • a sync worker retries in the background
  • conflicts are detected and resolved predictably

For production Flutter apps, two common choices:

Local DBStrengthWhen to choose
drift (SQLite)Strong querying, migrations, relational modelingBusiness apps with complex filters
isarFast object store, simple persistenceSimpler schemas, performance first

If you already rely on SQL on the server, drift often feels natural because you can mirror query patterns locally.

5.3 Outbox table: queue writes while offline#

Your local DB should include an outbox table with:

FieldExamplePurpose
idUUIDUnique event ID
entity"tasks"Which table
entity_idUUIDWhich row
op"insert" or "update" or "delete"Operation type
payloadJSON stringData to send
created_attimestampOrdering
retry_countintBackoff
last_errorstringDebugging

When user edits a task offline:

  1. 1
    update local tasks row immediately
  2. 2
    enqueue an outbox event
  3. 3
    mark row as dirty = true

5.4 Sync worker: retry with backoff#

Keep it simple:

  • run on app start
  • run when connectivity returns
  • run periodically while app is active

Pseudo-implementation outline:

Dart
Future<void> syncOutbox() async {
  final events = await localDb.outbox.getPending(limit: 50);
 
  for (final e in events) {
    try {
      await pushEventToSupabase(e);
      await localDb.outbox.markDone(e.id);
      await localDb.entities.clearDirty(e.entityId);
    } catch (err) {
      await localDb.outbox.markFailed(e.id, err.toString());
    }
  }
}

5.5 Pushing changes to Supabase safely#

For updates, include version to avoid overwriting newer data. One approach:

  • client sends version
  • server only applies update if version matches
  • server increments version

This is optimistic concurrency control.

On the server, you can enforce it with an RPC or a conditional update.

If you use a conditional update from Flutter, your update should target the row and the expected version.

Dart
Future<void> updateTaskWithVersion(String id, int expectedVersion, Map<String, dynamic> patch) async {
  final update = {
    ...patch,
    'client_updated_at': DateTime.now().toUtc().toIso8601String(),
  };
 
  final res = await supabase
      .from('tasks')
      .update(update)
      .eq('id', id)
      .eq('version', expectedVersion)
      .select('id, version')
      .maybeSingle();
 
  if (res == null) {
    throw Exception('Conflict: version mismatch');
  }
}

Then handle conflicts in your sync worker:

  • fetch latest server row
  • compare with local dirty row
  • apply your strategy: last-write-wins, merge fields, or user prompt

5.6 Pulling server changes efficiently#

Don’t refetch everything on every sync. Use updated_at watermarks.

Store a per-table checkpoint locally:

CheckpointExampleMeaning
tasks_last_sync2026-06-02T10:12:00ZLast successful pull

Then pull updates:

  • fetch rows with updated_at greater than checkpoint
  • apply upserts locally
  • advance checkpoint to max updated_at returned
Dart
Future<List<Map<String, dynamic>>> fetchTasksSince(String teamId, String sinceIso) async {
  return await supabase
      .from('tasks')
      .select('id, team_id, title, updated_at, deleted_at, version')
      .eq('team_id', teamId)
      .gt('updated_at', sinceIso)
      .order('updated_at');
}

5.7 Soft deletes and tombstones#

Hard delete breaks offline reconciliation because other devices may miss the delete event. Prefer soft delete:

  • set deleted_at
  • exclude deleted rows in queries by default
  • periodically purge deleted rows server-side if needed

In Flutter, treat delete as:

  • mark local row deleted_at = now and enqueue outbox delete event
  • hide it immediately from UI
  • sync later

# 6) Putting It Together: A Production Data Access Stack#

A stable pattern for Flutter Supabase auth realtime offline sync looks like this:

6.1 Repository pattern with local-first reads#

Rules of thumb:

  • UI reads from local DB streams
  • repository exposes watch methods returning streams
  • remote fetch updates local DB, not the UI directly
Method typeSourceExample
watchTasks(teamId)Local DBUI list
refreshTasks(teamId)Remote then localPull-to-refresh
editTask(task)Local then outboxInstant edits
sync()Outbox then pullBackground worker

6.2 Realtime integration point#

Realtime should call into the repository to patch local DB.

Example behavior on event:

  • if payload is a delete with deleted_at, mark as deleted locally
  • else upsert row locally
  • do not navigate or show snackbars automatically, let UI decide
ConcernRecommendationWhy
State managementRiverpodTestability, scoping, async ergonomics
Local DBdrift or isarMature, production-proven
Connectivityconnectivity_plusDetect network changes
Background workworkmanager (Android), background_fetch (iOS limits)Best-effort background sync
Loggingtalker or loggerDebugging sync and auth issues

💡 Tip: Build a “Sync Debug” screen in debug builds. Show outbox size, last sync time, last error, and current user id. This reduces production debugging time dramatically.

# 7) Common Gotchas in Production (and How to Avoid Them)#

7.1 Using the service role key in the app#

Never ship the service role key to clients. Use anon key only.

If you need privileged operations:

  • use Supabase Edge Functions
  • or server-side API under your control
  • keep RLS policies strict

7.2 Realtime seems unreliable#

Most “realtime is broken” issues are one of these:

  • RLS prevents the user from seeing the row
  • user is subscribed to wrong schema or table
  • filter mismatches, especially wrong column types
  • channel not unsubscribed, multiple subscriptions duplicate events

Fix by logging:

  • channel name
  • subscription status
  • payload ids and team ids
  • current auth user id

7.3 Timezone and ordering bugs#

Use UTC everywhere:

  • send DateTime.now().toUtc()
  • store timestamptz on the server
  • order by updated_at for sync, not client_updated_at

7.4 Offline creates and “missing rows”#

If you create a row offline and show it immediately, ensure it has a UUID and is inserted into local DB first. When syncing, insert it to Supabase using the same ID to avoid duplicates.

7.5 Excessive bandwidth#

You can easily burn through mobile data if you:

  • poll too frequently
  • refetch full lists after every change
  • subscribe broadly

Use:

  • incremental pulls with updated_at
  • realtime as patching, not refetch triggers
  • pagination and server-side filters

# 8) Testing Checklist for Auth, RLS, Realtime, and Offline#

You should test these scenarios before release:

ScenarioExpected behaviorHow to test
Token refreshUser stays logged inWait 1 hour, background app, reopen
RLS bypass attemptForbiddenCall REST with another user id
Offline editUI updates instantly, queued syncAirplane mode then edit
ConflictDeterministic resolutionEdit same row on two devices
Realtime updateOther device receives patchTwo devices on same team
LogoutLocal data handled safelyLogout with offline mode too

ℹ️ Note: RLS tests should include direct API calls using the anon key, not only in-app flows. The attacker model is “someone calls your Supabase endpoints directly.”

# Key Takeaways#

  • Treat Flutter Supabase auth realtime offline sync as four separate concerns: auth, RLS, realtime, and sync, then integrate them through repositories and a local DB.
  • Enforce permissions in PostgreSQL with RLS, default user_id to auth.uid(), and never rely on client-side authorization.
  • Use realtime narrowly with filters, subscribe only while screens are active, and apply events to a local cache instead of rebuilding UI from network payloads.
  • Implement offline-first UX using a local database plus an outbox queue, then sync with incremental pulls using updated_at checkpoints.
  • Add version-based optimistic concurrency to detect conflicts and resolve them with a defined strategy, not guesswork.
  • Build debugging tools early: outbox inspector, sync logs, and auth state visibility to reduce production incident time.

# Conclusion#

Flutter and Supabase can be a strong production combo when you design for untrusted clients, intermittent connectivity, and multi-device concurrency from day one. Start by locking down RLS, then build a local-first repository layer with an outbox and incremental sync, and finally layer realtime on top as a cache invalidation and patch mechanism.

If you want Samioda to review your Supabase RLS policies, implement an offline-first sync layer, or ship a production-ready Flutter architecture, contact us via our site and share your current schema and user flows.

FAQ

Share
A
Adrijan OmićevićSamioda Team
All articles →

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.