Implement username login
Enable username login by replacing email with username in the SuperTokens authentication flow.
Overview
This tutorial shows you how to customize the recipe to add username based login with an optional email field.
A few variations exist on how username-based flows can work:
This guide implements the second flow: Username and password login with optional email. If you are using one of the other options, you can still follow this guide and make tweaks on parts of it to achieve your desired flow.
The approach is to update the email
form field.
This way it gets displayed and validated as a username.
Then the optional email value gets saved against the userID
of the user and you use it during sign in and reset password flows. You need to handle the mapping of email to userID
and store it in your own database. The code snippets below create placeholder functions for you to implement.
Before you start
This guide assumes that you have already implemented the EmailPassword recipe and have a working application integrated with SuperTokens. If you have not, please check the Quickstart Guide.
Steps
1. Modify the default email validator function
Update the backend validator function to check for your username format. The function runs during sign up, sign in, and reset password. Hence it needs to also match an email format since the user might enter it when signing in or resetting their password.
Inside SuperTokens, the field is still called email
.
This ensures that the username is unique and that the authentication flows works.
Use the next code snippet as a reference.
import SuperTokens from "supertokens-node";
import EmailPassword from "supertokens-node/recipe/emailpassword";
SuperTokens.init({
appInfo: {
apiDomain: "...",
appName: "...",
websiteDomain: "..."
},
supertokens: {
connectionURI: "...",
},
recipeList: [
EmailPassword.init({
signUpFeature: {
formFields: [{
id: "email",
validate: async (value) => {
if (typeof value !== "string") {
return "Please provide a string input."
}
// first we check for if it's an email
if (
value.match(
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
) !== null
) {
return undefined;
}
// since it's not an email, we check for if it's a correct username
if (value.length < 3) {
return "Usernames must be at least 3 characters long."
}
if (!value.match(/^[a-z0-9_-]+$/)) {
return "Username must contain only alphanumeric, underscore or hyphen characters."
}
}
}]
}
})
]
});
2. Save the user email
2.1 Update the sign up form validation
The sign up API
takes in the username, password, and an optional email.
Add a new form field for the email, along with a validate
function that checks the uniqueness and syntax of the input email.
To check if the email is unique you need to persist values in your own database and then check against them.
import SuperTokens from "supertokens-node";
import EmailPassword from "supertokens-node/recipe/emailpassword";
let emailUserMap: {[key: string]: string} = {}
async function getUserUsingEmail(email: string): Promise<string | undefined> {
// TODO: Check your database for if the email is associated with a user
// and return that user ID if it is.
// this is just a placeholder implementation
return emailUserMap[email];
}
SuperTokens.init({
appInfo: {
apiDomain: "...",
appName: "...",
websiteDomain: "..."
},
supertokens: {
connectionURI: "...",
},
recipeList: [
EmailPassword.init({
signUpFeature: {
formFields: [{
id: "email",
validate: async (value) => {
// ...from previous code snippet...
return undefined
}
}, {
id: "actualEmail",
validate: async (value) => {
if (value === "") {
// this means that the user did not provide an email
return undefined;
}
if (
value.match(
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
) === null
) {
return "Email is invalid";
}
if ((await getUserUsingEmail(value)) !== undefined) {
return "Email already in use. Please sign in, or use another email"
}
},
optional: true
}]
}
})
]
});
2.2 Save the email field value
Override the sign up API to save the custom email form field.
Use a mapping of userID
to email
to keep track of the association.
Save the email value in your own database.
import SuperTokens from "supertokens-node";
import EmailPassword from "supertokens-node/recipe/emailpassword";
let emailUserMap: {[key: string]: string} = {}
async function getUserUsingEmail(email: string): Promise<string | undefined> {
// TODO: Check your database for if the email is associated with a user
// and return that user ID if it is.
// this is just a placeholder implementation
return emailUserMap[email];
}
async function saveEmailForUser(email: string, userId: string) {
// TODO: Save email and userId mapping
// this is just a placeholder implementation
emailUserMap[email] = userId
}
SuperTokens.init({
appInfo: {
apiDomain: "...",
appName: "...",
websiteDomain: "..."
},
supertokens: {
connectionURI: "...",
},
recipeList: [
EmailPassword.init({
override: {
apis: (original) => {
return {
...original,
signUpPOST: async function (input) {
let response = await original.signUpPOST!(input);
if (response.status === "OK") {
// sign up successful
let actualEmail = input.formFields.find(i => i.id === "actualEmail")!.value as string;
if (actualEmail === "") {
// User did not provide an email.
// This is possible since we set optional: true
// in the formField config
} else {
await saveEmailForUser(actualEmail, response.user.id)
}
}
return response
}
}
}
},
signUpFeature: {
formFields: [ /* ... from previous code snippet ... */]
}
})
]
});
3. Allow username or email during sign in
The user should be able to sign in using their email or username along with their password. In the new logic, if a user enters their email, you need to fetch the username associated with that email and then perform the authentication flow. Override the sign in recipe function to allow this.
Use the next code snippet as a reference. The example use the email
to userId
mapping, mentioned earlier, to figure out which username to use.
import SuperTokens from "supertokens-node";
import EmailPassword from "supertokens-node/recipe/emailpassword";
let emailUserMap: { [key: string]: string } = {}
async function getUserUsingEmail(email: string): Promise<string | undefined> {
// TODO: Check your database for if the email is associated with a user
// and return that user ID if it is.
// this is just a placeholder implementation
return emailUserMap[email];
}
async function saveEmailForUser(email: string, userId: string) {
// TODO: Save email and userId mapping
// this is just a placeholder implementation
emailUserMap[email] = userId
}
function isInputEmail(input: string): boolean {
return input.match(
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
) !== null;
}
SuperTokens.init({
appInfo: {
apiDomain: "...",
appName: "...",
websiteDomain: "..."
},
supertokens: {
connectionURI: "...",
},
recipeList: [
EmailPassword.init({
override: {
functions: (original) => {
return {
...original,
signIn: async function (input) {
if (isInputEmail(input.email)) {
let userId = await getUserUsingEmail(input.email);
if (userId !== undefined) {
let superTokensUser = await SuperTokens.getUser(userId);
if (superTokensUser !== undefined) {
// we find the right login method for this user
// based on the user ID.
let loginMethod = superTokensUser.loginMethods.find(lM => lM.recipeUserId.getAsString() === userId && lM.recipeId === "emailpassword");
if (loginMethod !== undefined) {
input.email = loginMethod.email!
}
}
}
}
return original.signIn(input);
}
}
},
apis: (original) => {
return {
...original,
// override from previous code snippet
}
}
},
signUpFeature: {
formFields: [ /* ... from previous code snippet ... */]
}
})
]
});
4. Allow username or email during password reset
The password reset flow requires the user to have added an email during sign up. If there is no email associated with the user, return an appropriate message.
To update the functionality you have to first change how the password reset token gets generated and then update the email sending logic. This way both methods take into account the new fields.
4.1 Override the token generation API
The user should enter either their username or their email when starting the password reset flow. Like the sign in customization, you must check if the input is an email and, if it is, retrieve the username associated with the email. If you can't find a username from an email you have to return an appropriate message to the frontend.
import SuperTokens from "supertokens-node";
import EmailPassword from "supertokens-node/recipe/emailpassword";
import supertokensTypes from "supertokens-node/types";
let emailUserMap: { [key: string]: string } = {}
async function getUserUsingEmail(email: string): Promise<string | undefined> {
// TODO: Check your database for if the email is associated with a user
// and return that user ID if it is.
// this is just a placeholder implementation
return emailUserMap[email];
}
async function saveEmailForUser(email: string, userId: string) {
// TODO: Save email and userId mapping
// this is just a placeholder implementation
emailUserMap[email] = userId
}
function isInputEmail(input: string): boolean {
return input.match(
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
) !== null;
}
async function getEmailUsingUserId(userId: string) {
// TODO: check your database mapping..
// this is just a placeholder implementation
let emails = Object.keys(emailUserMap)
for (let i = 0; i < emails.length; i++) {
if (emailUserMap[emails[i]] === userId) {
return emails[i]
}
}
return undefined;
}
SuperTokens.init({
appInfo: {
apiDomain: "...",
appName: "...",
websiteDomain: "..."
},
supertokens: {
connectionURI: "...",
},
recipeList: [
EmailPassword.init({
override: {
functions: (original) => {
return {
...original,
// ...override from previous code snippet...
}
},
apis: (original) => {
return {
...original,
// ...override from previous code snippet...
generatePasswordResetTokenPOST: async function (input) {
let emailOrUsername = input.formFields.find(i => i.id === "email")!.value as string;
if (isInputEmail(emailOrUsername)) {
let userId = await getUserUsingEmail(emailOrUsername);
if (userId !== undefined) {
let superTokensUser = await SuperTokens.getUser(userId);
if (superTokensUser !== undefined) {
// we find the right login method for this user
// based on the user ID.
let loginMethod = superTokensUser.loginMethods.find(lM => lM.recipeUserId.getAsString() === userId && lM.recipeId === "emailpassword");
if (loginMethod !== undefined) {
// we replace the input form field's array item
// to contain the username instead of the email.
input.formFields = input.formFields.filter(i => i.id !== "email")
input.formFields = [...input.formFields, {
id: "email",
value: loginMethod.email!
}]
}
}
}
}
let username = input.formFields.find(i => i.id === "email")!.value as string;
let superTokensUsers: supertokensTypes.User[] = await SuperTokens.listUsersByAccountInfo(input.tenantId, {
email: username
});
// from the list of users that have this email, we now find the one
// that has this email with the email password login method.
let targetUser = superTokensUsers.find(u => u.loginMethods.find(lM => lM.hasSameEmailAs(username) && lM.recipeId === "emailpassword") !== undefined);
if (targetUser !== undefined) {
if ((await getEmailUsingUserId(targetUser.id)) === undefined) {
return {
status: "GENERAL_ERROR",
message: "You need to add an email to your account for resetting your password. Please contact support."
}
}
}
return original.generatePasswordResetTokenPOST!(input);
},
}
}
},
signUpFeature: {
formFields: [ /* ... from previous code snippet ... */]
}
})
]
});
4.2 Override the email sending API
Update the email sending API to retrieve the user email if the user used a username in the password reset flow.
import SuperTokens from "supertokens-node";
import EmailPassword from "supertokens-node/recipe/emailpassword";
let emailUserMap: {[key: string]: string} = {}
async function getUserUsingEmail(email: string): Promise<string | undefined> {
// TODO: Check your database for if the email is associated with a user
// and return that user ID if it is.
// this is just a placeholder implementation
return emailUserMap[email];
}
async function saveEmailForUser(email: string, userId: string) {
// TODO: Save email and userId mapping
// this is just a placeholder implementation
emailUserMap[email] = userId
}
function isInputEmail(input: string): boolean {
return input.match(
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
) !== null;
}
async function getEmailUsingUserId(userId: string) {
// TODO: check your database mapping..
// this is just a placeholder implementation
let emails = Object.keys(emailUserMap)
for (let i = 0; i < emails.length; i++) {
if (emailUserMap[emails[i]] === userId) {
return emails[i]
}
}
return undefined;
}
SuperTokens.init({
appInfo: {
apiDomain: "...",
appName: "...",
websiteDomain: "..."
},
supertokens: {
connectionURI: "...",
},
recipeList: [
EmailPassword.init({
override: {
/* ...from previous code snippets... */
},
signUpFeature: {
formFields: [ /* ... from previous code snippet ... */]
},
emailDelivery: {
override: (original) => {
return {
...original,
sendEmail: async function (input) {
input.user.email = (await getEmailUsingUserId(input.user.id))!;
return original.sendEmail(input)
}
}
}
},
})
]
});
5. Show the new fields in the user interface
The following instructions are only relevant if you are using the pre-built UI.
If you created your own custom UI on the frontend, please make sure to pass the new email formField
when you call the sign up function.
Even if the user has not given an email, you must add it with an empty string.
Update the pre-built UI to reflect the new flow:
- Skip frontend validation for the
email
field since username or email is permissible. The backend performs those checks. - Change the "Email" label in the sign up form to say "Username".
- Add an extra field in the sign up form where the user can enter their email.
- Change the "Email" label in the sign in form to say "Username or email".
- Change the "Email" label in the password reset form to say "Username or email".
- Update translations for the email field if necessary.
import SuperTokens from "supertokens-auth-react"
import EmailPassword from "supertokens-auth-react/recipe/emailpassword"
SuperTokens.init({
appInfo: {
appName: "...",
apiDomain: "...",
websiteDomain: "...",
},
languageTranslations: {
translations: {
"en": {
EMAIL_PASSWORD_EMAIL_LABEL: "Username or email"
}
}
},
recipeList: [
EmailPassword.init({
signInAndUpFeature: {
signInForm: {
formFields: [{
id: "email",
label: "Username or email",
placeholder: "Username or email"
}]
},
signUpForm: {
formFields: [{
id: "email",
label: "Username",
placeholder: "Username",
validate: async (input) => {
// the backend validates this anyway. So nothing required here
return undefined;
}
}, {
id: "actualEmail",
validate: async (input) => {
// the backend validates this anyway. So nothing required here
return undefined
},
label: "Email",
optional: true
}]
}
}
}),
// other recipes initialisation..
],
});