Flutter Supabase Auth and CRUD with a Compatible Backend

Flutter Supabase auth and CRUD take four pieces: a table with a Row Level Security policy, a client initialized once with your project URL and anon key, signUp / signInWithPassword with an auth-state listener that swaps screens, and from('table') queries for insert, select, update and delete. Because the database enforces who owns which rows, the Flutter code never filters by user.

This guide builds a small todo app end to end. The code uses StitchDB's Flutter package, stitchdb_flutter, which has the same methods as supabase_flutter. If you already use supabase_flutter, the same calls work against a StitchDB project too: the backend speaks the same Auth, REST and Realtime APIs, so you only change the URL and key (compatibility).

#What you'll build

  • E-mail and password sign-up and sign-in
  • An auth gate that shows the login screen or the app
  • Create, read, update and delete todos, only your own
  • A live list that updates when rows change
  • OAuth sign-in with a deep link back to the app

#1. Create the table and policy

In your project's SQL editor (or a migration):

SQL
create table public.todos (
  id bigint generated always as identity primary key,
  user_id uuid not null default auth.uid() references auth.users on delete cascade,
  task text not null check (char_length(task) between 1 and 500),
  is_complete boolean not null default false,
  created_at timestamptz not null default now()
);

alter table public.todos enable row level security;

create policy "Users manage their own todos" on public.todos
  for all to authenticated
  using (user_id = auth.uid())
  with check (user_id = auth.uid());

create index on public.todos (user_id);

-- Send changes of this table to Realtime subscribers
alter publication stitchdb_realtime add table public.todos;

user_id defaults to auth.uid(), so inserts from the app don't need to send it, and the with check clause stops anyone from writing a row for another user. On StitchDB the realtime publication is stitchdb_realtime; you can also tick Enable Realtime in Studio.

#2. Add the package

stitchdb_flutter is installed from GitHub. Add it to pubspec.yaml with the overrides from the Flutter SDK docs, which point its sub-packages at the same repository:

Terminal
dependencies:
  stitchdb_flutter:
    git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_flutter }

dependency_overrides:
  stitchdb:           { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb } }
  stitchdb_auth:      { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_auth } }
  stitchdb_common:    { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_common } }
  stitchdb_functions: { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_functions } }
  stitchdb_postgrest: { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_postgrest } }
  stitchdb_realtime:  { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_realtime } }
  stitchdb_storage:   { git: { url: https://github.com/stitchdb/stitchdb-flutter, path: packages/stitchdb_storage } }

Then run flutter pub get. It needs Dart 3.9 or newer.

#3. Initialize the client once

dart
import 'package:flutter/material.dart';
import 'package:stitchdb_flutter/stitchdb_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Stitch.initialize(
    url: 'https://<project-ref>.p.stitchdb.com',
    anonKey: '<anon-key>',
  );
  runApp(const TodoApp());
}

final stitchdb = Stitch.instance.client;

Use the anon key from Settings → API. It is safe in an app because RLS decides what it can do. Never put the service_role key in a Flutter build: it bypasses RLS and anyone can extract it from the binary.

In a real app, pass the values with --dart-define instead of hard-coding them, so staging and production builds point at different projects.

#4. Sign-up, sign-in and an auth gate

The auth gate listens to onAuthStateChange and shows the right screen. The session is stored on the device and refreshed automatically, so users stay signed in between launches.

dart
class TodoApp extends StatelessWidget {
  const TodoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: StreamBuilder(
        stream: stitchdb.auth.onAuthStateChange,
        builder: (context, _) {
          final user = stitchdb.auth.currentUser;
          return user == null ? const LoginPage() : const TodoPage();
        },
      ),
    );
  }
}

The login page calls two methods:

dart
Future<void> signUp(String email, String password) async {
  await stitchdb.auth.signUp(email: email, password: password);
  // With "Confirm e-mail" on (the default), no session yet:
  // tell the user to check their inbox.
}

Future<void> signIn(String email, String password) async {
  try {
    await stitchdb.auth.signInWithPassword(email: email, password: password);
  } on AuthException catch (e) {
    // e.statusCode, e.code, e.message: show a friendly error
    debugPrint('Sign-in failed: ${e.message}');
  }
}

Future<void> signOut() => stitchdb.auth.signOut();

When Confirm e-mail is on, signUp returns no session and the user confirms through a link. Either keep it on and handle the link with a deep link (next step), or turn it off in Authentication → Sign-in methods while you develop.

Magic links, confirmation e-mails and OAuth providers send the user back to your app through a deep link such as io.myapp://login-callback.

  1. Add io.myapp://login-callback to the project's Redirect URLs in Authentication → URL configuration (redirect URLs).
  2. Register the scheme in your Android intent filter and iOS URL types.
  3. Pass it as the redirect:
dart
await stitchdb.auth.signInWithOAuth(
  OAuthProvider.github,
  redirectTo: 'io.myapp://login-callback',
);

await stitchdb.auth.signInWithOtp(
  email: '[email protected]',
  emailRedirectTo: 'io.myapp://login-callback',
);

The SDK opens the browser, receives the deep link and stores the session; the auth gate then switches to the app. For OAuth, set each provider's callback URL to https://<project-ref>.p.stitchdb.com/auth/v1/callback (OAuth providers).

If you'd rather not build auth screens at all, signInWithHostedPage(redirectTo: ...) opens the project's hosted sign-in page and returns the session through the same deep link.

#6. CRUD

Keep queries in one small repository class. Rows come back as List<Map<String, dynamic>>; map them to a model.

dart
class Todo {
  Todo({required this.id, required this.task, required this.isComplete});
  final int id;
  final String task;
  final bool isComplete;

  factory Todo.fromRow(Map<String, dynamic> r) =>
      Todo(id: r['id'] as int, task: r['task'] as String, isComplete: r['is_complete'] as bool);
}

class TodoRepository {
  Future<List<Todo>> list() async {
    final rows = await stitchdb
        .from('todos')
        .select('id, task, is_complete')
        .order('created_at', ascending: false)
        .range(0, 49);
    return rows.map(Todo.fromRow).toList();
  }

  Future<Todo> create(String task) async {
    final row = await stitchdb.from('todos').insert({'task': task}).select().single();
    return Todo.fromRow(row);
  }

  Future<void> toggle(Todo t) =>
      stitchdb.from('todos').update({'is_complete': !t.isComplete}).eq('id', t.id);

  Future<void> remove(int id) => stitchdb.from('todos').delete().eq('id', id);

  Future<int> total() =>
      stitchdb.from('todos').count(CountOption.exact);
}

Notice what's missing: no .eq('user_id', ...) anywhere. The policy from step 1 limits every query to the signed-in user's rows, on reads and writes. If another user's id somehow ends up in a request, the database rejects it.

Handle database errors where you call the repository:

dart
try {
  await repo.create('');
} on PostgrestException catch (e) {
  // The check constraint fails for an empty task: e.code is '23514'
  showError(e.message);
}

#7. A live list with Realtime

For a list that updates itself, use stream() on the table. It loads the current rows and then applies inserts, updates and deletes as they happen:

dart
class TodoPage extends StatelessWidget {
  const TodoPage({super.key});

  @override
  Widget build(BuildContext context) {
    final stream = stitchdb.from('todos').stream(primaryKey: ['id']);
    return Scaffold(
      appBar: AppBar(title: const Text('Todos')),
      body: StreamBuilder<List<Map<String, dynamic>>>(
        stream: stream,
        builder: (context, snap) {
          if (!snap.hasData) return const Center(child: CircularProgressIndicator());
          final todos = snap.data!.map(Todo.fromRow).toList();
          return ListView(
            children: [
              for (final t in todos)
                CheckboxListTile(
                  title: Text(t.task),
                  value: t.isComplete,
                  onChanged: (_) => TodoRepository().toggle(t),
                ),
            ],
          );
        },
      ),
    );
  }
}

Realtime applies the same RLS policies: each subscriber only receives rows they're allowed to select. For finer control, subscribe to a channel with onPostgresChanges(event: PostgresChangeEvent.insert, schema: 'public', table: 'todos', callback: ...) and handle each event yourself.

#8. Check your security before shipping

  • Open Advisors in Studio. It flags tables with RLS off and realtime tables without RLS (advisors).
  • Use Database → Policies → Check access to see what a specific user can read or change.
  • Sign in as two different test users and confirm neither sees the other's todos.

#Supabase or StitchDB for a Flutter app

Both work with the same Flutter code shape. Supabase has the official supabase_flutter package on pub.dev, more tutorials and regions worldwide; if your users are in the Americas or Asia, that matters. StitchDB hosts in the EU only and adds mobile-oriented features in the same SDK: push notifications, in-app purchase verification, feature flags and offline sync with a conflict rule per table (offline sync). Moving an existing Supabase project over keeps users' passwords and ids (import). For a wider comparison, see the Flutter backend guide.

#FAQ

#Can I use supabase_flutter with StitchDB?

Yes. StitchDB speaks the same Auth, REST, Storage and Realtime APIs. Initialize supabase_flutter with your StitchDB project URL and pass the anon key as its key (recent versions name that parameter publishableKey).

#Why does my Flutter query return an empty list?

RLS is on and no policy allows the request, or the user isn't signed in yet. Check that a session exists and that a select policy matches.

#How do I keep users signed in between app launches?

The Flutter package stores the session on the device and refreshes the access token automatically. Read auth.currentUser at startup.

#Do I need to send user_id when inserting?

Not if the column defaults to auth.uid(). The with check policy also prevents writing rows for other users.

#How do I get live updates in Flutter?

Use from('todos').stream(primaryKey: ['id']) with a StreamBuilder, after adding the table to the realtime publication.

Build it on a free StitchDB project, or bring your existing app with the compatibility guide.