How to use Supabase with Firebase Auth in FlutterFlow

FlutterFlow's success as a low-code tool has truly set it apart in the modern tech landscape, and one of its key differentiators is indeed that – it's low code, not no-code.
Coming from a coding background myself, I could immediately see how the tool could complete gaps in my own knowledge, without holding me back in terms of flexibility. And with the rise of AI tools and editors like Cursor and Windsurf, even those with zero coding knowledge are finding ways to write code that gets their applications working the way they want.
But there are always trade-offs.
One key issue I found as my FlutterFlow knowledge evolved was that the Firebase integration was great, except... for Firebase's NoSQL database offering, Firestore. Vendor-lock, high costs, and the lack of a relational database were such common complaints around Firestore that the Supabase integration was created to address them.
However, row-level-security (RLS) in Supabase doesn't play nice with Firebase Auth, meaning users of Supabase are obliged to use Supabase Auth, which doesn't play nice with Firebase Cloud Messaging (for push notifications), meaning writing custom code, setting up another push service like OneSignal, and so on. Using Supabase broke a lot if the convenient features of the Firebase Integration, and suddenly FlutterFlow didn't feel so seamless any more. I began feeling the need to ask:
How are people with no coding knowledge supposed to figure all this out?
In this article, I'll introduce my solution on how to use the Supabase database with RLS without ditching Firebase Auth, allowing all the other features of Firebase to work as expected. This solution even preserves the existing Firestore, which you can easily keep within the free tier since now Supabase is doing all the heavy lifting.
The Problem
Let's look at Firebase Cloud Messaging (FCM) as an example of this problem, push notifications being a fundamental reason why people even favour mobile applications to begin with, despite all the advances in web application development. FlutterFlow will auto-create certain cloud functions when you enable push notifications in the console, and one important job these cloud functions have is to save users' device tokens as a subcollection on their record in the Firestore users
collection.
The users
collection automatically syncs with Firebase Auth, so that when a user is created in Firebase Auth, a users
document is also created. Then, when using actions in FlutterFlow to a send push, everything breaks as soon as Firebase Auth and the Firestore users
collection go away.
There's nothing stopping you from enabling the Supabase integration in FlutterFlow without turning on Supabase Auth; all that happens is that the request attaches the Supabase Anon key to its headers, and this will work.
The problem is RLS.
And let's not mince words – Supabase without RLS is basically worthless, unless you have a backend server acting as a middleman.
The Solution
At first I was using a backend to proxy all my requests to Supabase, passing the Postgrest request as-is so I wouldn't need to write CRUD routes explicitly, minting and caching Supabase Auth tokens on the fly. But recently I found a better way.
It turns out that the Firebase Auth SDK has a method called .userChanges()
that is capable of monitoring the JWT token from Firebase Auth. If the token changes, an action can be performed. So I wrote a cloud function that uses the Supabase JWT Secret to mint a Supabase token each time the Firebase token changes. This might happen on token refresh (every hour), but it also works on startup.
Then, I take the freshly minted Supabase JWT token and attach it to the Supabase client, so that this header will always be added to each Supabase request. Here's the custom function:
// Automatic FlutterFlow imports
import '/backend/backend.dart';
import '/backend/schema/structs/index.dart';
import '/backend/supabase/supabase.dart';
import '/flutter_flow/flutter_flow_theme.dart';
import '/flutter_flow/flutter_flow_util.dart';
import '/custom_code/actions/index.dart'; // Imports other custom actions
import '/flutter_flow/custom_functions.dart'; // Imports custom functions
import 'package:flutter/material.dart';
// Begin custom action code
// DO NOT REMOVE OR MODIFY THE CODE ABOVE!
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
import 'package:cloud_functions/cloud_functions.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
// We'll store the subscription at a global or static level
// so it persists beyond this function call.
StreamSubscription<firebase_auth.User?>? _authWatcher;
Future<void> updateSupabaseToken() async {
try {
// 1) If we haven't already set up the listener, do it now.
if (_authWatcher == null) {
_authWatcher = firebase_auth.FirebaseAuth.instance
.userChanges()
.listen((firebase_auth.User? user) async {
if (user == null) {
// User signed out
// Optionally sign out from Supabase
await Supabase.instance.client.auth.signOut();
debugPrint('Signed out of Supabase because Firebase user is null.');
} else {
// Update the token whenever the user logs in or refreshes
await _fetchAndSetSupabaseToken(user);
}
});
debugPrint('Auth watcher set up successfully.');
} else {
debugPrint('Auth watcher already set up.');
}
// 2) If a user is currently signed in, do an immediate token update.
final user = firebase_auth.FirebaseAuth.instance.currentUser;
if (user != null) {
await _fetchAndSetSupabaseToken(user);
} else {
// If user == null, optionally sign out from Supabase right now
// in case you want a truly "no user" state.
await Supabase.instance.client.auth.signOut();
debugPrint('No user currently signed in; signed out from Supabase.');
}
} catch (e, st) {
debugPrint('Error in updateSupabaseTokenAction: $e $st');
}
}
// Helper to fetch the token and update Supabase
Future<void> _fetchAndSetSupabaseToken(firebase_auth.User user) async {
try {
final firebaseToken = await user.getIdToken();
final callable =
FirebaseFunctions.instance.httpsCallable('mintSupabaseToken');
final response = await callable.call({
'token': firebaseToken,
'userId': user.uid,
// pass any extra fields needed by your CF
});
final data = response.data;
if (data == null || data['access_token'] == null) {
debugPrint('No supaToken in Cloud Function response!');
return;
}
final supaToken = data['access_token'];
Supabase.instance.client.headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer $supaToken',
};
Supabase.instance.client.rest.setAuth(supaToken);
debugPrint('Supabase token updated successfully for user ${user.uid}.');
} catch (e, st) {
debugPrint('Error fetching/setting Supabase token: $e $st');
}
}
You can copy the above code as a custom action right into FlutterFlow and call it updateSupabaseToken
. It might be a good idea to only copy the part that reads // DO NOT REMOVE OR MODIFY THE CODE ABOVE! because those imports are specific to your project. But note that the imports for supabase and firebase cloud function must be included.
Next we need to add this custom action to main.dart
, which ensures that the _authWatcher
watcher will be ever-present and listening for token changes. For this, you simply use the Final Actions
section in FlutteFlow's main.dart editor and add the the newly created updateSupabaseToken
action to it.
The third and final step is to add a cloud function that will securely mint our Supabase tokens. We'll need the jsonwebtoken
javascript library, so add this to package.json
.
{
"name": "functions",
"description": "Firebase Custom Cloud Functions",
"engines": {
"node": "18"
},
"main": "index.js",
"dependencies": {
"firebase-admin": "^11.8.0",
"firebase-functions": "^4.3.1",
"jsonwebtoken": "^9.0.2"
},
"private": true
}
And then create a cloud function named mintSupabaseToken
:
const { onRequest } = require('firebase-functions/v2/https');
const admin = require('firebase-admin');
const jwt = require('jsonwebtoken');
const logger = require('firebase-functions/logger');
// Initialize Firebase Admin
if (!admin.apps.length) {
admin.initializeApp();
}
function generateJwt(userId, isAdmin = false) {
const nowSec = Math.floor(Date.now() / 1000);
const expSec = nowSec + ( 2 * 60 * 60); // 2 hours
const payload = {
sub: userId,
user_id: userId,
iss: "supabase",
role: isAdmin ? 'admin' : 'authenticated',
iat: nowSec,
exp: expSec,
};
return jwt.sign(payload, process.env.SUPABASE_JWT_SECRET, { algorithm: 'HS256' });
}
function getToken(userId, role) {
// Generate new JWT
const isAdmin = role === 'admin';
const newToken = generateJwt(userId, isAdmin);
return newToken;
}
async function getCurrentUserFromHeaders(token) {
try {
return await admin.auth().verifyIdToken(token);
} catch (err) {
logger.error('Error verifying Firebase ID token:', err);
throw new Error('Unauthorized');
}
}
exports.mintSupabaseToken = onRequest(async (req, res) => {
// Handle CORS preflight manually (if your front-end is calling from a different domain)
if (req.method === 'OPTIONS') {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
return res.status(204).send('');
}
// Only accept POST for the main flow
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method Not Allowed. Use POST or OPTIONS.' });
}
// Ensure we have JSON
if (!req.body) {
return res.status(400).json({ error: 'Missing JSON in request body.' });
}
// Extract the data portion
const { data } = req.body;
if (!data) {
return res.status(400).json({ error: "Missing 'data' field in request." });
}
// Check for the Firebase token
const { token } = data;
if (!token) {
return res.status(400).json({ error: "Missing 'token' in request data." });
}
// Verify the Firebase ID token to get the user
let currentUser;
try {
currentUser = await getCurrentUserFromHeaders(token);
} catch (e) {
return res.status(401).json({ error: e.message });
}
// Generate (or retrieve from cache) the Supabase token
const mintedToken = getToken(currentUser.uid, currentUser.role);
// Optionally log out the user & minted token (for debugging)
logger.info(`User ${currentUser.uid} minted Supabase token: ${mintedToken.slice(0, 10)}...`);
// Build the response
const result = {
data: {
status: 200,
access_token: mintedToken,
},
};
// If needed, return CORS headers
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.set('Access-Control-Allow-Methods', 'POST, OPTIONS');
return res.status(200).json(result);
});
In your Supabase dashboard, navigate to Project Settings > API > JWT Settings and copy the JWT Secret. After deploying the "mintSupabaseToken" function in FlutterFlow, head to your Google Cloud Platform console and make sure the right project is selected. Click on the newly created "mintSupabaseToken" function, then "Edit". Under "Runtime, build, connections, and security settings", there's a section called "Runtime environment variables". Add a new variable called "SUPABASE_JWT_SECRET" and paste the value from your Supabase dashboard. Then redeploy the function.
Now you may use Supabase in your FlutterFlow app alongside Firebase Auth.
Troubleshooting
If you run into any issues, here are a few things to check:
- If
updateSupabaseToken
refuses to parse, it might be because you haven't yet created themintSupabaseToken
cloud function. One references the other, and also FlutterFlow won't import the cloud functions package until a cloud function has been created. - Some of the imports in the code shown above might not work as expect and break the build. This will be because you're using or not using certain FlutterFlow features. To fix this, remove all the code, add the boilerplate code that FlutterFlow provides, then add only the code below the
// DO NOT REMOVE OR MODIFY THE CODE ABOVE!
declaration. - One notable example of the above point reported by some users is
import '/backend/schema/structs/index.dart';
which is causing a compile error in the - Make sure the
updateSupabaseToken
action is added to themain.dart
file as a final action - Be sure the names of the custom action and cloud function match the declaration.
updateSupabaseToken
code. This will be because you don't have any data types defined. Simply remove this import statement to fix this. Final considerations and caveats
It's common practice in Supabase to use Supabase Auth to store critical user information, but to have a separate "users" table for less critical data. That way your app can allow users to see each other's profiles (if desirable) and modify their data (like profile pictures) without risking a user tampering with data they shouldn't have access to (e.g. an is_admin
or is_pro_member
flag).
In the above approach, you do the same thing, but this time the critical users' data stays in Firebase Auth. It's necessary to keep the Firestore users table synchronized with your custom Supabase users table. Although this sync does add extra maintenance, it can be worth it because now you can add sensitve flags in Firestore and close this collection from user modification using prohibitive Firestore rules.
Beware though, that for very sensitive data, like a role: admin flag, this may not be sufficient and you may want to think about using custom claims for extra security.
Incidentally, you might have noticed that in my mintSupabaseToken
function, I've added a flag called isAdmin
. If your Firebase JWT has a custom claim called role: admin
, this allows the Supabase token to also carry this flag, and so you can set up admin privileges in Supabase RLS policies. To do so you need a PostgresSQL role called "admin", but this is a topic for another article.
Also, since the UID of the user comes from Firebase Auth, you should not use a different primary key for the users table in Supabase, instead, simply use the Firebase UID as the primary key in Supabase (it's fine to change the primary key's type from int
to text
). Then you just have to add a step in the onboarding flow in FlutterFlow to create this user in Supabase whenever on signup.
Hopefully this method of reconciliation to solve the Firebase vs Supabase issue will work for you. If you want to learn more or work with me on your next FlutterFlow project, please feel free to get in touch!