Authentication looked straightforward when all I needed was a way for users to sign in.
It became more interesting while I was building Loomwing, a custom-fashion marketplace application with two distinct user types: customers and tailors.
A customer needs a customer experience. A tailor needs a tailor experience. But I did not want returning users to choose their role every time they signed in.
That raised a more useful architectural question:
Once Firebase knows who a user is, how should the application determine what kind of user they are?
The solution I settled on was to separate identity from application-specific profile data.
Firebase Authentication handles identity.
Cloud Firestore stores the Loomwing profile, including the user's role.
The application then uses that persisted role to decide which experience to load.
The architecture
At a high level, I separated authentication from application-specific user data.
Firebase Authentication is responsible for identifying the user. Once a user signs in, Firebase gives the application a unique user ID — the UID.
Loomwing then uses that UID to look for a corresponding profile in Cloud Firestore:
users/{uid}
That Firestore document contains the information Loomwing needs beyond basic authentication, including the user's display name, authentication provider, and most importantly, their application role.
For now, a Loomwing user can have one of two roles:
customertailor
This separation means the login screen does not have to decide what kind of user someone is every time they return. Once a role has been selected during account creation, the persisted Firestore profile becomes the source of truth for the application's role-based experience.
In simple terms:
Firebase Authentication tells Loomwing who the user is. Firestore tells Loomwing what kind of user they are.
Initializing Firebase
The first thing I wanted was one place where Firebase would be initialized and reused across the app.
I didn't want authentication logic scattered across different React components, so I created a small Firebase module that exports both Authentication and Firestore.
import { getApp, getApps, initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
const requiredConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID
};
export const firebaseApp =
getApps().length > 0 ? getApp() : initializeApp(requiredConfig);
export const auth = getAuth(firebaseApp);
export const db = getFirestore(firebaseApp);
The getApps() check is there to make sure Firebase doesn't initialize the default app more than once.
My configuration values come from Vite environment variables instead of being written directly into the module.
For example:
VITE_FIREBASE_API_KEY
VITE_FIREBASE_AUTH_DOMAIN
VITE_FIREBASE_PROJECT_ID
VITE_FIREBASE_STORAGE_BUCKET
VITE_FIREBASE_MESSAGING_SENDER_ID
VITE_FIREBASE_APP_ID
One thing I had to understand here is that VITE_* variables are still exposed to the client bundle. They're useful for keeping configuration organized between environments, but they aren't a place for server-side secrets.
I also keep my actual .env.local file out of Git and use an .env.example file to document the variables the project expects.
Creating an account and saving the role
When somebody creates a Loomwing account, I need more than their email and password.
I also need to know how they intend to use the platform.
Right now, Loomwing has two roles:
customertailor
So during signup, the user chooses their role once.
The Firebase account is created first:
const credential =
await createUserWithEmailAndPassword(auth, email, password);
firebaseUser = credential.user;
await updateProfile(firebaseUser, {
displayName: fullName
});
await writeProfile(
firebaseUser,
fullName,
role,
'password'
);
After that, Loomwing creates the application profile in Firestore:
await setDoc(doc(db, 'users', user.uid), {
uid: user.uid,
email: user.email ?? '',
displayName,
role,
authProvider,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp()
});
The profile lives at:
users/{uid}
That means the Firebase Authentication account and the Loomwing profile are connected by the same UID.
I liked this approach because Firebase handles the identity side, while Firestore holds information that belongs specifically to Loomwing.
The role is one of those pieces of application-specific information.
It is stored in Firestore, not as a Firebase custom claim.
Signing in without asking for a role again
This was one of the first things I wanted to clean up from the earlier version of Loomwing.
During development, it can be tempting to have buttons like:
Sign in as Customer
Sign in as Tailor
That makes testing easy, but I didn't want that to become part of the real authentication system.
Once an account already exists, the user shouldn't have to keep telling Loomwing what role they belong to.
Email/password sign-in uses Firebase normally:
const credential =
await signInWithEmailAndPassword(auth, email, password);
After Firebase authenticates the account, Loomwing reads the profile using the user's UID:
const snapshot = await getDoc(
doc(db, 'users', user.uid)
);
The flow becomes:
Email + Password
↓
Firebase Authentication
↓
Firebase UID
↓
users/{uid}
↓
Saved role
↓
Customer or Tailor experience
The important part is this:
The sign-in form doesn't decide the user's role. The stored profile does.
That felt much cleaner than making role selection part of every login.
Google Sign-In was a little more complicated
Email/password signup already gives me a chance to ask whether someone is joining as a customer or tailor.
Google Sign-In doesn't.
Firebase can authenticate the Google account, but it still doesn't know what that person is supposed to be inside Loomwing.
The authentication itself is straightforward:
const credential =
await signInWithPopup(
auth,
new GoogleAuthProvider()
);
return await resolveFirebaseUser(
credential.user
);
The interesting part happens afterwards.
Loomwing checks whether the authenticated Firebase user already has a profile at:
users/{uid}
If the profile exists, I load it and continue.
If it doesn't exist, the user is asked to choose a role before the Loomwing profile is created.
The flow looks like this:
Continue with Google
↓
Firebase authenticates user
↓
Does users/{uid} exist?
/ \
Yes No
↓ ↓
Load profile Ask for role
↓
Create profile
↓
Continue
I initially thought of this as a "new Google user" problem, but that's not quite accurate.
What really matters is whether the Loomwing profile exists.
The implementation doesn't depend on Google's isNewUser metadata. It checks Firestore.
That also covers cases where a Firebase account exists but the Loomwing profile is missing.
If the user closes the onboarding flow without finishing it, Loomwing signs them back out instead of leaving the app stuck in a half-finished authentication state.
Firebase identity alone wasn't enough
This is probably the part of the implementation that changed how I think about authentication the most.
At first, it's easy to think:
Firebase says the user is signed in, so the application session is ready.
For Loomwing, that isn't always true.
The application needs both:
Firebase authenticated user
+
valid Firestore users/{uid} profile
=
Loomwing application user
When Loomwing reads the Firestore document, it doesn't just accept whatever data happens to be there.
The profile is validated.
The role must be either:
customer
or:
tailor
The authentication provider must also be either:
password
or:
google
The saved profile is then converted into the user object Loomwing actually works with:
const toApplicationUser = (
user: FirebaseUser,
profile: LoomwingUserProfile
): User => ({
id: profile.uid,
name: profile.displayName,
email: profile.email,
role: profile.role,
avatarUrl: user.photoURL ?? undefined,
createdAt: profile.createdAt.toDate().toISOString()
});
The line that matters most here is:
role: profile.role
That persisted role becomes part of the real application session.
Restoring the session after refresh
The next thing I had to handle was refreshes.
Signing in successfully isn't very useful if refreshing the browser suddenly makes the application behave like the user is logged out.
Firebase gives us onAuthStateChanged() for this.
Loomwing subscribes to it when the app starts:
const unsubscribe = onAuthStateChanged(
auth,
async (firebaseUser) => {
const currentRevision = ++revision;
if (!firebaseUser) {
if (!disposed) {
listener({ status: 'signed-out' });
}
return;
}
const resolution =
await resolveFirebaseUser(firebaseUser);
if (
!disposed &&
currentRevision === revision
) {
listener(resolution);
}
}
);
So when Loomwing loads, the process is roughly:
Application starts
↓
Wait for Firebase
↓
Auth state resolves
↓
Authenticated?
/ \
No Yes
↓ ↓
Landing Read profile
↓
Restore session
The app stays in a loading state until Firebase has resolved that initial authentication state.
There is also a small detail in this implementation that I think is worth mentioning.
Reading the Firestore profile is asynchronous.
That means an older profile request could theoretically finish after a newer authentication event.
The revision value prevents that old result from replacing the newer state.
It's a small piece of code, but it's the kind of thing I probably wouldn't have thought much about when I was only building basic login screens.
Firestore rules are part of the auth system too
Getting somebody signed in is only one part of the job.
I also had to think about what that authenticated user should actually be allowed to read or change.
For the users collection, Loomwing first checks that the signed-in Firebase user owns the document being accessed:
function isSignedInAs(uid) {
return request.auth != null
&& request.auth.uid == uid;
}
The profile rules then use that check:
match /users/{uid} {
allow read: if isSignedInAs(uid);
allow create: if isSignedInAs(uid)
&& hasValidProfileFields(uid)
&& request.resource.data.createdAt == request.time
&& request.resource.data.updatedAt == request.time;
allow update: if isSignedInAs(uid)
&& hasValidProfileFields(uid)
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['email', 'displayName', 'updatedAt'])
&& request.resource.data.updatedAt == request.time;
allow delete: if false;
}
A normal profile update can change:
email
displayName
updatedAt
But it can't change:
uid
role
authProvider
createdAt
That means somebody can't use a normal client-side profile update to turn:
role: "customer"
into:
role: "tailor"
I didn't want the role to be nothing more than a frontend setting.
Once the role affects what somebody can do inside the application, it also has to be treated as part of the application's security model.
Authentication doesn't mean onboarding is finished
I ran into another interesting case when I started testing real Tailor accounts.
A user could be:
Authenticated: yes
Role: tailor
Tailor profile: missing
Those aren't the same thing.
Earlier versions of the app had mock tailor data available, but I didn't want a real authenticated Tailor to suddenly inherit some placeholder profile just because a real profile hadn't been created yet.
So Loomwing looks for a TailorProfile that actually belongs to the authenticated user.
If it doesn't find one, the result stays null.
Instead of showing fake information, the application shows:
Set up your tailor profile
Opportunities, proposals, and orders stay unavailable until that real profile exists.
The actual profile editor is still a later product step.
And I'm okay with that.
I'd rather the app clearly show that something isn't finished than quietly attach placeholder information to a real account.
This also helped me separate three ideas that I used to think of as one thing:
Authentication
Who is this person?
Application role
Are they a customer or tailor?
Onboarding
Do they have all the information needed to use the features for that role?
A user can successfully complete authentication and still have onboarding work left to do.
Deploying it with Firebase Hosting
Getting everything working locally was one thing.
Getting it working after deployment exposed a couple of configuration mistakes.
The first one was the Hosting directory.
Loomwing uses Vite, so the production build goes into:
dist
Firebase Hosting therefore needs to serve that folder:
{
"hosting": {
"public": "dist",
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}
The rewrite sends application routes back through index.html, which is what I need for the single-page application.
I also ran into an issue when deploying my Firestore rules.
At first, Hosting existed in firebase.json, but Firestore wasn't configured there.
So Firebase had no Firestore deployment target.
The configuration needed this as well:
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
}
}
Once that was in place, I could build:
npm run build
and deploy Hosting and Firestore rules together:
firebase deploy --only "hosting,firestore:rules"
Neither mistake was especially complicated once I understood what was wrong.
But they reminded me that getting a feature working on localhost is only part of finishing it.
Deployment is part of the system too.
What I learned
When I started working on this, I mostly thought of authentication as the part where somebody enters an email and password and gets access to the app.
After building this version of Loomwing, I don't really see it that way anymore.
The login form is probably the easiest part.
The more important questions were:
- Who is this person?
- What kind of Loomwing user are they?
- What information do I trust?
- What should they be allowed to change?
- What happens when they're authenticated but their profile isn't complete?
- What happens when they refresh the page?
The mental model I ended up with is:
Firebase Authentication
"Who are you?"
Firestore profile
"What kind of Loomwing user are you?"
Application logic
"What should you be able to do?"
Separating those responsibilities made the whole system easier for me to reason about.
It also gives me a cleaner foundation for the next parts of Loomwing, especially proper TailorProfiles, requests, proposals, measurements, orders, and the other role-specific workflows that will sit on top of this authentication layer.
There's still a lot I want to improve, but this version is a long way from the mock authentication flow I started with.
And more importantly, I understand why it's structured this way now.
If you're building a multi-role application with Firebase, I'd be interested to know how you've handled the separation between authentication, roles, and onboarding.






Top comments (2)
Beautifully and well written. Welldone!
Thanks, really appreciate it 😁🙏