Most modern apps need usernames. We don’t want to share our email addresses publicly, using a long user ID would look odd. Firebase doesn’t handle usernames out of the box, but you can use them with a few minor modifications.
TL;DR#
When adding a new username, a separate usernames
collection will complement the users
collection. This will enforce uniqueness and allow for easy searchability. To save speed and reads, you should also add the username to a custom claim that can be accessible directly in the ID Token.
Collections#
Depending on our app, our username could have three main points of reference:
usernames/{username}
document- The field
username
in theusers/{user_id}
document - In the custom claim of the
currentUser
object
📝 We need the usernames
collection to maintain distinct usernames. Firestore does not handle unique values, so we must use a document ID to emulate this behavior.
Adding a Username#
When we add a username, we should add it to both collections simultaneously. Here, I use firebase-admin
, but you could also do this on the client.
const batch = adminDB.batch();
batch.set(
adminDB.doc(`usernames/${username}`),
{
uid,
username
}
);
batch.set(
adminDB.doc(`users/${uid}`),
{
username
},
{ merge: true }
);
await batch.commit();
However, we should also delete the old username to keep our records clean. This allows you to use the same method for updating a username as for adding a new one.
const uid = token.uid;
// get current username if exists
const querySnap = await adminDB
.collection('usernames')
.where('uid', '==', uid)
.get();
const batch = adminDB.batch();
// delete current username if exists
if (!querySnap.empty) {
querySnap.docs.forEach((snap) => {
batch.delete(
adminDB.doc(`usernames/${snap.id}`)
)
});
}
// add new username
try {
batch.set(
adminDB.doc(`usernames/${username}`),
{
uid,
username
}
);
batch.set(
adminDB.doc(`users/${uid}`),
{
username
},
{ merge: true }
);
await batch.commit();
} catch (e) {
if (e instanceof FirebaseError) {
error(400, e.message);
}
}
📝 The error()
method is in SvelteKit, but you could use whatever method your SSR framework uses to handle errors. You could also use a callable function if your app is only client-side.
Custom Claims#
It is extremely easy to add a username
custom claim on the backend.
try {
await adminAuth.setCustomUserClaims(uid, {
username
});
} catch (e) {
if (e instanceof FirebaseError) {
error(400, e.message);
}
}
⚠️ You cannot create custom claims on the client side; hence, it is recommended that you use the backend for the entire function.
📝 Keeping everything on the client side prevents you from having complex Firestore Rules when adding a username. If you keep these functions on the server, make the usernames
collection read-only
. See Firestore Rules.
Refreshing Token on the Client#
Anytime you update a custom claim, you should update the client token.
await currentUser.getIdToken(true);
This should be done after your backend function is safely finished running.
Verify the User#
Before adding any custom claims, you should always verify the user. You must pass the idToken
to the server or Firebase function to do this.
export const verifyIdToken = async (idToken: string) => {
try {
const token = await adminAuth.verifyIdToken(idToken);
return {
token,
error: null
};
} catch (e) {
if (e instanceof FirebaseError) {
return {
error: e.message,
token: null
};
}
}
return {
error: null,
token: null
};
};
Usage#
const { token, error: tokenError } = await verifyIdToken(idToken);
if (!token || tokenError) {
error(401, 'Unauthorized!');
}
Checking for Valid Username#
If you want to check for a valid username, you check for a username in the usernames
collection.
export const usernameAvailable = async (
username: string
) => {
try {
const snap = await getDoc(
doc(db, 'usernames', username)
);
return {
available: !snap.exists(),
error: null
};
} catch (e) {
if (e instanceof FirebaseError) {
return {
error: e.message,
available: null
};
}
}
return {
error: null,
available: null
};
};
Debounce#
You can check while typing in an input field, but you should wait a few seconds before fetching Firebase to prevent unnecessary reads.
// reusable debounce function
export function useDebounce<F extends (
...args: Parameters<F>
) => ReturnType<F>>(
func: F,
waitFor: number,
): (...args: Parameters<F>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<F>): void => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), waitFor);
};
}
Then you call it with oninput
. This varies depending on your framework.
const debouceUsername = useDebounce(() => { // do something here });
<input oninput={debounceUsername} ... />
This reusable hook should work anywhere.
const { available, error } = await usernameAvailable(username);
Login with Username Feature#
It is worth noting that you could also simulate a login with username feature, even though this is NOT supported out-of-the-box, by simply using the username to fetch the user's UID. This would have to be done on the backend, and would use firebase-admin
as well.
Example#
Demo: Vercel Serverless
Repo: GitHub
📝 Remember firebase-admin
will ONLY work in NodeJS Environments. Use a callable Firebase Function to deploy to Bun, Vercel Edge, Cloudflare, or Deno environments.