Going full-stack with Flutter and Supabase - Part 1: Authentication

·

14 min read

Going full-stack with Flutter and Supabase - Part 1: Authentication

Introduction

In this tutorial series, we will build a simple notes app, powered by Supabase. Supabase is a product similar to Firebase, which provides services such as authentication and a database, as well as a client which allows you to authenticate and query the database directly from your Flutter app. By combining Flutter with Supabase, you could build production-ready* apps without having to write or maintain a back-end server.

This series will consist of three parts, each one going over a specific service by Supabase. First, we'll start with Authentication, which will allow users to sign in the app. Then, we'll move on to the Database, where we'll create a simple schema for our notes app, and allow users to create and view their notes. Last, we'll check out Storage, which we'll make use of by allowing users to attach files to their notes.

This tutorial assumes you already have some experience with Flutter (if not, go here to get started!). Code snippets will be shared as we go along, but you can also check out the full source code at the repository linked at the end of this post.

* Note that Supabase is still in public beta phase. However, it's still an exciting service, and now is a good time to experiment with it!

What is Supabase?

Supabase calls itself an open source alternative to Firebase. It offers several things: a relational database which you can access directly from the client, built-in user authentication so you can manage users and permissions, as well as storage so you can store large files. Functions are also in the works, which will allow you to run your own code without needing a server.

Why not Firebase?

While Supabase is considered an alternative, it's also very different. As always, there is no one right choice, and the best service to use depends on your use case. But Supabase has two stand-out features that Firebase does not have, which a lot of developers might prefer.

  • Relational database: Supabase is built on top of PostgreSQL, a powerful relational database that has been around for over 30 years. Firebase's Firestore and Realtime Database offerings are non-relational, so the choice between Supabase and Firebase might be as easy as relational vs non-relational, though there's more to it than that. Firebase is not open source, and has not been around as long as Postgres.
  • It's all open source: Supabase is built on top of other popular open-source packages, such as PostgREST for accessing your database directly from the client, and GoTrue for user authentication. All that, along with a nice admin interface so you can manage everything. If you wanted, you could host and manage these services on your own. If PostgREST does not fit your use case, you could host a server that will communicate with your Postgres database directly. And if you ever decide that Supabase is not for you, you could always migrate all your data somewhere else. At the end of the day, it's all in a Postgres database.

Getting Started

Setting up Supabase

Getting started with Supabase is simple, so we won't go into much detail about its setup. Just head to the Supabase website and create a project.

You can spend some time to get familiar with the admin interface, but we won't be needing it a lot for this first part. All we will need is your Supabase project's URL and API key, which you can find your project's API settings.

In auth settings, you can also have some options to change your site URL, which is included in emails sent by Supabase when a user signs up (which you can disable), as well as customize the email templates sent for password resets, magic links(!), and invitations.

You can also set up and run Supabase locally for local development by following the guide here.

Creating a new Flutter app

Now that we've got Supabase all set up, it's time to start building the app. Let's create a new Flutter app. Since this is a notes app built on Supabase, I'll be calling it Supanotes!

flutter create supanotes

Note: if null safety was not already enabled by default, you can do this by running the command below.

cd supanotes
dart migrate --apply-changes

We'll be making use of Dart's null safety features, but it for whatever reason you don't want to enable it for your app, you should still be able to follow along.

Part 1: Authentication

Now, let's make a very simple login page. Here is the UI code so far:

class SupanotesApp extends StatelessWidget {
  static const supabaseGreen = Color.fromRGBO(101, 217, 165, 1.0);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Supanotes',
      theme: ThemeData(
        brightness: Brightness.dark,
        primarySwatch: toMaterialColor(supabaseGreen),
      ),
      home: HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage();

  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  void _signUp() {
    // Sign up logic will go here
  }

  void _signIn() {
    // Sign in logic will go here
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).primaryColor,
        title: Text('Supanotes'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: TextField(
                controller: _emailController,
                keyboardType: TextInputType.emailAddress,
                decoration: InputDecoration(hintText: 'Email'),
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: TextField(
                controller: _passwordController_,
                obscureText: true,
                decoration: InputDecoration(hintText: 'Password'),
              ),
            ),
            ElevatedButton.icon(
              onPressed: _signIn,
              icon: Icon(Icons.login),
              label: Text('Sign in'),
            ),
            ElevatedButton.icon(
              onPressed: _signUp,
              icon: Icon(Icons.app_registration),
              label: Text('Sign up'),
            ),
          ],
        ),
      ),
    );
  }

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }
}

The toMaterialColor function is taken from this post here (thanks Filip!).

Adding the Supabase package

Let's implement the logic for sign in and sign up. First, we add the supabase package to our app.

flutter pub add supabase

Next, we'll create an AuthService that will handle anything auth related. In order to pass this service to any widget that needs it, we'll create an InheritedWidget that will hold all services we might need.

class Services extends InheritedWidget {
  final AuthService authService;

  Services._({
    required this.authService,
    required Widget child,
  }) : super(child: child);

  factory Services({required Widget child}) {
    final client = SupabaseClient(supabaseUrl, supabaseUrl);
    final authService = AuthService(client.auth);
    return Services._(authService: authService, child: child);
  }

  @override
  bool updateShouldNotify(InheritedWidget oldWidget) {
    return false;
  }

  static Services of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<Services>()!;
  }
}

In this InheritedWidget, we initialize the Supabase client, and create the AuthService. To initialise the SupabaseClient, simply pass the URL and API keys from the API settings on the Supabase admin interface.

The GoTrue client

Supabase uses GoTrue for authentication. client.auth returns the GoTrue client, which is all the AuthService will need.

class AuthService {
  final GoTrueClient _client;

  AuthService(this._client);

  Future<bool> signUp(String email, String password) {
    // sign up logic goes here
  }

  Future<bool> signIn(String email, String password) {
    // sign in logic goes here
  }

  Future<bool> signOut() {
    // sign out logic goes here
  }
}

All three functions will return true if the response was successful, otherwise false.

Let's start with the signUp function:

// AuthService
Future<bool> signUp(String email, String password) async {
  final response = await _client.signUp(email, password);
  if (response.error == null) {
    log('Sign up was successful for user ID: ${response.user!.id}');
    return true;
  }
  log('Sign up error: ${response.error!.message}');
  return false;
}

This function will accept the email and password which we will pass from the text field widgets, and create a user. The client will return a GotrueSessionResponse, which will contain an error if something goes wrong, or a User and Session object.

We don't currently need to do anything with the User and Session objects, though it contains useful information we could make use of, such as the user's ID, last sign in date, and email confirmation date. However, these will always be accessible through GoTrueClient#currentUser and GoTrueClient#currentSession so we can grab them from the client when we need them.

Below are also the signIn and signOut functions, which are very similar to signUp.

// AuthService
Future<bool> signIn(String email, String password) async {
  final response = await _client.signIn(email: email, password: password);
  if (response.error == null) {
    log('Sign in was successful for user ID: ${response.user!.id}');
    return true;
  }
  log('Sign in error: ${response.error!.message}');
  return false;
}

Future<bool> signOut() async {
  final response = await _client.signOut();
  if (response.error == null) {
    return true;
  }
  log('Log out error: ${response.error!.message}');
  return false;
}

Now, let's call these AuthService functions in HomePage. Since the service is part of the Services InheritedWidget, we can freely access it through Services.of(context).authService. But to do that, we should first wrap MaterialApp with the Services widget. Our top level app widget should look like this:

class SupanotesApp extends StatelessWidget {
  static const supabaseGreen = Color.fromRGBO(101, 217, 165, 1.0);

  const SupanotesApp();

  @override
  Widget build(BuildContext context) {
    return Services(
      child: MaterialApp(
        title: 'Supanotes',
        theme: ThemeData(
          brightness: Brightness.dark,
          primarySwatch: toMaterialColor(supabaseGreen),
        ),
        home: HomePage(),
      ),
    );
  }
}

Now, we can call AuthService functions from any widget. Let's start with the logic for sign up.

// HomePage
void _signUp() async {
  final success = await Services.of(context)
      .authService
      .signUp(_emailController.text, _passwordController.text);
  if (success) {
    await Navigator.pushReplacement(
        context, MaterialPageRoute(builder: (_) => NotesPage()));
  } else {
    ScaffoldMessenger.of(context)
        .showSnackBar(SnackBar(content: Text('Something went wrong.')));
  }
}

If the response was successful, we navigate to a new page, the NotesPage, where we will display the user's notes. We call pushReplacement rather than push, since we don't want the user pressing back and ending up in the home page by mistake.

If the response fails, we just show a generic snack bar. This could display more useful information, based on the actual error.

The function for signing in is pretty much the same. We can reuse some of the code for both functions, and our code now looks like this:

// HomePage
void _signUp() async {
  final success = await Services.of(context)
      .authService
      .signUp(_emailController.text, _passwordController.text);
  await _handleResponse(success);
}

void _signIn() async {
  final success = await Services.of(context)
      .authService
      .signIn(_emailController.text, _passwordController.text);
  await _handleResponse(success);
}

Future<void> _handleResponse(bool success) async {
  if (success) {
    await Navigator.pushReplacement(
        context, MaterialPageRoute(builder: (_) => NotesPage()));
  } else {
    ScaffoldMessenger.of(context)
        .showSnackBar(SnackBar(content: Text('Something went wrong.')));
  }
}

Now let's build the NotesPage. For now, it'll just be an empty page, with a button to sign out. Here's the full code:

class NotesPage extends StatelessWidget {
  const NotesPage();

  Future<void> _signOut(BuildContext context) async {
    final success = await Services.of(context).authService.signOut();
    if (success) {
      Navigator.pushReplacement(
          context, MaterialPageRoute(builder: (_) => HomePage()));
    } else {
      ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('There was an issue logging out.')));
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Supanotes'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Padding(
              padding: const EdgeInsets.all(8.0),
              child: Text('Your notes will show up here.'),
            ),
            ElevatedButton.icon(
              onPressed: () async {
                await _signOut(context);
              },
              icon: Icon(Icons.login),
              label: Text('Sign out'),
            ),
          ],
        ),
      ),
    );
  }
}

And that's it! We now have a proper authentication system set up for our Supanotes app. Now any requests to the Supabase client will include the signed in user info as well, so we can continue with the next part: creating and listing notes.

GoTrue and JSON Web Tokens

GoTrue uses JSON Web Tokens (JWT) for authentication. On a successful sign in/sign up, the response contains an access token, and a refresh token. The access token is included in every request to Supabase. When decoded, this token contains the user's unique ID, and this is how Supabase knows which user is performing the request. Access tokens are short-lived, and expire by default every one hour (you can configure this through the admin interface, but only do this if you have a good reason). You can read more about JWT here.

The access token is only valid for a short time, and we definitely don't want the user having to sign in every one hour. This is what the refresh token is for. The refresh token is valid for longer than an hour, and can be used to request new tokens. Let's take a look at some of the source code of the GoTrueClient:

void _saveSession(Session session) {
  currentSession = session;
  currentUser = session.user;
  final tokenExpirySeconds = session.expiresIn;

  if (autoRefreshToken && tokenExpirySeconds != null) {
    if (_refreshTokenTimer != null) _refreshTokenTimer!.cancel();

    final timerDuration = Duration(seconds: tokenExpirySeconds - 60);
    _refreshTokenTimer = Timer(timerDuration, () {
      _callRefreshToken();
    });
  }
}

If autoRefreshToken is set to true, a timer is set to automatically "refresh" the tokens by using the refresh token, one minute before the access token expires. autoRefreshToken is true by default, and can be changed through an optional parameter in the GoTrueClient constructor.

Now, what happens if the user exits the app? We lose access to both tokens, and have no way of requesting new tokens either, so the user would have to sign in again.

Keeping users signed in

To keep users signed in even after re-opening the app, we need to somehow request new tokens. GoTrueClient has a recoverSession function, which uses the refresh token mentioned before to request new tokens.

In order to recover the session, we need to persist the session data of a successful sign in. In this example, we'll use the shared_preferences package, but you might also want to use something like flutter_secure_storage if there are security concerns. Data stored in shared preferences is only accessible by the app which stored them, but this is not the case for rooted devices.

flutter pub add shared_preferences

Conveniently, the Session object in the GoTrueResponse contains a function to get the JSON string to persist. So all we have to do is store this string in SharedPreferences, and fetch it when we want to recover the session. Here's the updated signUp and signIn functions:

// AuthService
static const supabaseSessionKey = 'supabase_session';

Future<bool> signUp(String email, String password) async {
  final response = await _client.signUp(email, password);
  if (response.error == null) {
    log('Sign up was successful for user ID: ${response.user!.id}');
    _persistSession(response.data!);
    return true;
  }
  log('Sign up error: ${response.error!.message}');
  return false;
}

Future<bool> signIn(String email, String password) async {
  final response = await _client.signIn(email: email, password: password);
  if (response.error == null) {
    log('Sign in was successful for user ID: ${response.user!.id}');
    _persistSession(response.data!);
    return true;
  }
  log('Sign in error: ${response.error!.message}');
  return false;
}

Future<void> _persistSession(Session session) async {
  final prefs = await SharedPreferences.getInstance();
  log('Persisting session string');
  await prefs.setString(supabaseSessionKey, session.persistSessionString);
}

Note that it might make sense to initialize SharedPreferences once and pass it to any services that might need it, but let's keep it like this for simplicity.

Okay, so we have all we need to recover the session after the user exists the app. What now? In order to decide which page widget to show (HomePage if user is not signed in, NotesPage otherwise), we can check if there is a persisted session string, and attempt to recover the session with GoTrueClient#recoverSession. If there's no session string stored, or if recovering the session fails, then we can show the HomePage. If recovering the session works, then the user is successfully signed in! We can go straight to the NotesPage.

Let's introduce a new function in AuthService, that will attempt to recover the session.

// AuthService
Future<bool> recoverSession() async {
  final prefs = await SharedPreferences.getInstance();
  if (prefs.containsKey(supabaseSessionKey)) {
    log('Found persisted session string, attempting to recover session');
    final jsonStr = prefs.getString(supabaseSessionKey)!;
    final response = await _client.recoverSession(jsonStr);
    if (response.error == null) {
      log('Session successfully recovered for user ID: ${response.user!.id}');
      _persistSession(response.data!);
      return true;
    }
  }
  return false;
}

Next, let's call this function to decide which page to show first. Here's the updated build function of the root SupanotesApp widget:

@override
Widget build(BuildContext context) {
  return Services(
    child: MaterialApp(
      title: 'Supanotes',
      theme: ThemeData(
        brightness: Brightness.dark,
        primarySwatch: toMaterialColor(supabaseGreen),
      ),
      home: Builder(
        builder: (context) {
          return FutureBuilder<bool>(
            future: Services.of(context).authService.recoverSession(),
            builder: (context, snapshot) {
              final sessionRecovered = snapshot.data ?? false;
              return sessionRecovered ? NotesPage() : HomePage();
            },
          );
        },
      ),
    ),
  );
}

Note that we are wrapping the FutureBuilder with a Builder, as the Services object will not be available in the context of SupanotesApp.

And that's it! If you now try to perform a hot restart, or exit and re-open the app, you'll see that you'll still be signed in! If the refresh token has expired, the recoverSession function will attempt to refresh the tokens, otherwise it will resume with the persisted session.

The GoTrueClient is not a big class, so I'd recommend taking a better look at the source code if you're interested in seeing how it all works behind the scenes.

There is one more thing we need to do. When a user signs out, we should clear this persisted session string. Otherwise, if a user signs out and then re-opens the app, they could be signed back in!

Here's the updated signOut function:

Future<bool> signOut() async {
  final response = await _client.signOut();
  if (response.error == null) {
    log('Successfully logged out; clearing session string');
    final prefs = await SharedPreferences.getInstance();
    prefs.remove(supabaseSessionKey);
    return true;
  }
  log('Log out error: ${response.error!.message}');
  return false;
}

More auth features

Supabase authentication also supports third party logins, but we won't show an example of how to use this functionality in this post. These OAuth providers are supported:

  • Google
  • GitHub
  • GitLab
  • Azure
  • Facebook
  • BitBucket
  • Apple
  • Twitter

There is also support for magic links! If you call the GoTrueClient#signIn function without a password, this will send the user an email with a one-time login link. Both third party logins and magic links could be good potential blog posts, so watch this space!

Wrapping up

That's all for the first part of this tutorial series on Supabase with Flutter, focusing on Authentication. You can find the full code on GitHub. I hope you found it helpful, and would love it if you could share some feedback in the comments! In the next part, we will take a look at Supabase's Database offering.

If you'd like to be notified when the next part of this series is published, and for any future tutorials, please sign up with your email below.

Did you find this article valuable?

Support Christos by becoming a sponsor. Any amount is appreciated!