8 min read
September 13 2024
Sometimes it’s nice to have a third party, such as Clerk, handle the user authentication flow for us. This can result in a minimal-effort setup and easy user management. But what if we want complete control over how things look and function? Moreover, what if we want to know exactly what happens behind the scenes? Well, then we can build our own authentication system.
My goal is to teach you the logic behind every line of code we’ll write together. So, throughout this detailed tutorial, we’ll be thinking critically about what makes sense to happen. Let’s jump into it 🚀
The tutorial assumes you have a basic knowledge of Firebase, Next JS, and React. We won’t dive into the details of connecting Firebase, so please refer to these docs if needed. We’ll be using JWT and cookies, don’t worry if you’re unfamiliar with them — I’ll cover them here.
Let’s tackle the frontend first. Here’s how my route structure is organized /app/(auth)/signup/page.tsx. In Next.js, to create a folder solely for organization, we wrap it in parentheses.
The frontend is pretty self-explanatory, but I’ll leave some comments inside the code.
If your goal is to learn, I strongly suggest you NOT copy any code 💁♀️. Instead, focus on understanding the logic behind it. Once you grasp the logic you won’t need to memorize anything.
"use client"; //this component renders on the client side
export default function SignUp() {
const router = useRouter(); //router for redirecting the user
const fields = [ //we'll map over our fields to avoid code repetition
{
name: "name",
type: "text",
},
{
name: "email",
type: "email",
},
{
name: "password",
type: "password",
},
];
const [data, setData] = useState({
name: "",
email: "",
password: "",
}); //initializing our data
const onChange = (e) => { //simultaneously handling input changes
const { name, value } = e.target; //destructuring
setData((prevState) => ({
...prevState, //creating an instance copy with the spread operator
[name]: value, //for each name there's a value
}));
};
const onSubmit = async (e) => {
e.preventDefault();
try {
//we'll create our API endpoint in the next step
const response = await axios.post("/api/signup", data); //sending data
if (response.status === 200) { //if success then redirect
router.push("/login");
}
} catch (err) {
console.log(err); //ofc error handling should be stronger IRL
}
};
return (
<>
<div className="flex w-full h-full justify-center items-center">
<form
onSubmit={onSubmit}
className="p-4 flex flex-col gap-4 border w-fit m-4 rounded-md"
>
{fields.map((field, i) => (
<input
key={i}
className="outline-none"
placeholder={field.name}
name={field.name}
type={field.type}
onChange={onChange}
/>
))}
<button className="hover:bg-slate-100 border py-2 px-8 rounded-md">
sign up
</button>
</form>
</div>
</>
);
}
Next.js is a fullstack framework, meaning it allows us to handle frontend and backend all in one place. That’s why our backend lives in a folder called api. In Next.js, to send a request from our client (frontend), we need to create an API endpoint that will receive it, process it, and send back a response.
Having said that, the structure for our server (backend) is going to look like this: /app/api/(auth)/signup/route.js. Notice how our server has a route.js inside it, contrary to page.js which is for client-side only.
Let’s think, or better even reason, about what needs to happen:
export async function POST(req) {
const { name, email, password } = await req.json() //awaiting our JSON
try {
//our code will be here...
}
catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
3. It would now make sense to check if the user with the email we received from our client already exists in the database, right? Right. So we’ll have to query a specific collection in our database where the user’s email is equal to the email we got from our client. See? That’s our function right there!
const userQuery = query(collection(db, "users"), where("email", "==", email));
const querySnapshot = await getDocs(userQuery); //all docs that match our query
if (!querySnapshot.empty) { //if there's any that matched
return NextResponse.json( //we return a response
{ error: "User already exists!" }, //saying that the user already exists
{ status: 400 }
);
}
4. So we handled the case above. Now, what if the user doesn’t exist? We need to create one, in other words we need to add a document (think addDoc). But before that it’d be nice to hash our password. We can easily do it with bcrypt. We know that we need to hash and salt (add a random string to our hash multiple times) our password, so let’s do it.
//...install and import bcrypt
const salt = await bcrypt.getSalt(10)
const hashedPassword = await bcrypt.hash(password, salt)
5. Ok, done! Let’s finally add a new user to our users collection, shall we?
const newUser = await addDoc(collection(db, "users"), { //passing our data
name: name,
email: email,
password: hashedPassword
})
6. It would be useful to know if our user has been successfully created, and if not it would be just as nice to handle the case where it wasn’t. How can we check if the user was added? I think we can do it by checking if the document we had just created exists.
const newUserDoc = await getDoc(newUser); //getting a specific doc
if (newUserDoc.exists()) {
return NextResponse.json({
message: "User successfully created",
success: true,
data: newUserDoc.data(), //returning our user data
}, {status: 200});
}
Give yourself a pat on the back — you nailed the /api/signup route! Now, go ahead and try to fill out the form and console log its response, you should see a success message. You can also just check your Firebase to see if the user dropped in there.
The frontend for our log in is exactly the same except for it redirects to our “/”, and it’s posting to /api/login with email and password, so practice and see if you can write the code yourself.
Let’s reason through our login process 💪
export async function POST(req) {
const { email, password } = await req.json();
try {
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
2. Similarly to how we did in signup, we’d like to know if the user with such email already exists in our collection. In this case however, if they do it’s a green light for us to carry on with the process, so we want to retrieve that specific user.
const userQuery = query(collection(db, "users"), where("email", "==", email));
const querySnapshot = await getDocs(userQuery); //all docs that match
if (querySnapshot.empty) { //if none matched
return NextResponse.json(
{ error: "User does not exist!" },
{ status: 400 }
);
}
//there can only be one user associated with every email so to retrieve
//a specific user we'll always need to tap into the 0th position of the array
const userDoc = querySnapshot).docs[0].data();
3. So we verified that there’s a user indeed under such email, but that’s not enough for us, we also want to verify that the passwords they enter match, right? Let’s go ahead and utilize our handy bcrypt.compare function to take care of that.
const isPasswordMatch = await bcrypt.compare(password, userDoc.password);
if (!isPasswordMatch) { //if there's no match
return NextResponse.json(
{ error: "Passwords do not match!" },
{ status: 400 }
);
}
4. So we confirmed that both email and passwords match, and handled the scenarios in which they don’t. Now we need to generate a JWT for our user, and set it onto our cookie. You may wonder what a JSON Web Token is, in a nutshell a JWT is like a digital pass that contains our user’s identity and is verified by the signature called “secret key”. The pass can have an expiration date for security reasons. Here’s more about it for the curious ones.
For a real world app make sure to come up with a strong JWT secret. Always keep it in your .env, because it’s a sensitive piece of information.
A cookie in its turn is a small piece of data that lives inside our browser, and remembers things about our user, so they don’t need to log in every single time they visit our user. When the JWT that was set to a cookie expires, in other words the session expires, the user needs to log in again.
Lastly, it’s useful to know that a cookie “lives” on our response, so we need to access the response from Next JS, tap into the cookie and set the token to it.
Enough with the terms, let’s practice!
//this is what forms our user's identity
//if you have more fields you want associated with your user add them here
const tokenData = {
email: userDoc.email,
password: userDoc.password,
};
const token = await jwt.sign(tokenData, process.env.JWT, {
expiresIn: "30d", //we want our user session to expire in 30 days
});
const response = NextResponse.json({ //getting hold of our success response
message: "Successfully logged in",
success: true
});
response.cookies.set("token", token, { httpOnly: true }); //setting the cookie
return response //returning our data so we can access it l
Let’s try to log in. We’ll get redirected to our (so far un)protected route, and if we check Application — cookies in our browser’s console we’ll see that there’s a token value there which means we’ve successfully created a user identity. Great job so far!
If you try to navigate to what’s supposed to be a protected route now you’ll discover that you can do it regardless of whether you’re signed in or not. That’s not right. The same is true for our /login and /signup, we should not be able to navigate to these routes if we’re already logged in.
To solve this issue we need to write a middleware (middleware.js) function in our root directory. What is middleware?
In the most basic terms, a middleware is like a waiter, it takes a client’s order (intercepts the request), and tells the kitchen about it (communicates a relevant piece of info between a client and a server). If you want to know more you can read here.
Let’s think about what we need to write an efficient middleware function. We need to define which routes are public and which aren’t, as well as configure all of the routes that are affected by our middleware. Moreover, it would be nice to know where on our website the user currently is (think request URLs).
If a route is protected and the user requests access we need to check if they have a token, because if they do it means they are logged in. See, thinking in logical terms really makes things clearer. Even if we forgot the code we can simply think through the steps it takes to write it.
import { NextResponse } from "next/server";
export function middleware(req) { //we need the request object
const currentPath = req.nextUrl.pathname; //accessing current pathname
//defining what routes are public
const isPublic = currentPath === "/login" || currentPath === "/signup";
//retrieving the token to see if the user's logged in
const token = req.cookies.get("token")?.value || "";
//if it's a public route and they are logged in we want them navigated away
//if there are some other public routes you want accessible to both logged
//in and logged out users you can define them additionally as you see fit
if (isPublic && token.length > 0) {
return NextResponse.redirect(new URL("/", req.nextUrl));
}
//if it's a private, and there's no token - we redirect to the login page
if (!isPublic && !token.length > 0) {
return NextResponse.redirect(new URL("/login", req.nextUrl));
}
}
export const config = {
matcher: ["/", "/login", "/signup"], //we want our middleware to affect these routes
};
Looking good! Let’s try to navigate to our public /signup and /login pages and see if we get bounced back to our “/” directory. Ready to move onto the next step?
See our user is stuck with us now, so let’s give them a way to get out of our application. Let’s take a moment to think about what it means to “get logged out”. It can be an automatic or a voluntary action. How come? Because to really get logged out our user’s token must expire first. There are two ways a token can expire, either if it reaches its predefined expiration date (automatic), or if a user “expires” it themselves (the expiration date is set to the moment the user clicks the log out button). We can only set an expiration date if we tap into our cookie, which lives on our response.
That sounds like a lot of work, huh? Not really, this is a rare case when it’s easier done than said 😂
import { NextResponse } from "next/server";
export async function GET() {
try {
const response = NextResponse.json( //getting our success response
{
message: "Successfully logged out",
success: true,
},
{ status: 200 }
);
response.cookies.set("token", "", { //setting the cookie to an empty string
httpOnly: true,
expiresIn: new Date(0), //expiring it immediately
});
return response; //returning the response we'll access in the client
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
Wow! Look at you, you’ve gone soooo far. Don’t worry, we’re nearing the end of this super long read. I hope you learned a bunch already, but look at how empty our homepage looks. It would be nice to greet our logged in user, wouldn’t it be?
But how can we get access to all the data stored on our user object? Well, we might begin by retrieving some information from the token that’s stored on our cookie. For that, there’s a useful jwt.verify() function. And then querying the database to get all of the information that belongs to this user, if they exist. Finally, all we need to do is just return that data so we can fetch it by sending a GET request with axios and useEffect() inside our client.
export async function GET(req) {
try {
//getting and verifying our token
const token = await req.cookies.get("token")?.value || "";
//verifying it and tapping into the email that's part of the token data
const userEmail = jwt.verify(token, process.env.JWT).email;
const userQuery = query(
collection(db, "users"),
where("email", "==", userEmail)
); //looking for a user with such email inside our database
const querySnapshot = await getDocs(userQuery); //getting all the matches
const userDocRef = querySnapshot.docs[0].ref; //getting a reference
const userDoc = await getDoc(userDocRef); //getting specific doc by its ref
return NextResponse.json(
{
message: "Successfully fetched user data",
success: true,
data: userDoc.data(), //returning user data to later fetch in client
},
{ status: 200 }
);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
Phew! That’s a lot to take in and process, but remember as long as you understand the logic behind the lines of code, you don’t need to memorize anything. Even if you want to build the same thing with Node JS instead, the only thing that would be different is syntax, the logic would be the same. That’s why it’s so important to use your power of logical thinking.