Why I Architectured Authentication from Scratch — And Why You Should Too

"Why don't you just use Auth0?"

The modern JavaScript ecosystem, polluted with an endless stream of framework after framework, makes one thing abundantly clear — no technology is here to stay.

jQuery revolutionized the game in 2006 and ruled the web development scene, until it was usurped in 2010 by AngularJS. Then in 2013 React was introduced, and took the world by storm. The king of frontend frameworks was swiftly followed by Vue.js in 2014 and Svelte in 2016. Point is, all these technologies offer promises of "revolutionary" innovations that never fail to alter the entire web development ecosystem. React may look a monolith that defines the entire ecosystem, but it collapses when Meta does (I would not mind that future for the privacy-snooping weirdos at Meta).

Granted, the backend side of web development is not as turbulent as the frontend ecosystem is. The same truths, however, are still applicable.

Concepts are here to stay. Frameworks and services, the application of such concepts, are not.

This is why I opted to learn the fundamentals of the industry standards for best practices for the authentication in my full-stack application. Perhaps using a third party service like Auth0 would have simplified the process, but that would have taken away from my opportunity to study how authentication is actually handled. Learning how to implement Auth0 in an application may be sufficient today, but will fail when Auth0 inevitably dies out. The fundamental concepts of authentication, however, are much, much slower to change.

It's actually not that difficult.

The process may, understandably, seem daunting at first. Once you can wrap your head around the basic principles, though, the implementation becomes fairly simple. Let me walk you through the basic process. Let's say we are creating an app in which users can upload photos of their doodles, and other users can view said drawings. The code samples are taken from the API I wrote for the fullstack application using Express.js and Node.js, written in TypeScript.

  • First, we create the user. We accept an email and password from the user and run a series of tests until we save the user in our database
export const register = async (req: Request, res: Response) => {
    try {
        //find an existing user in the collection storing the User model
        let doesExist = await User.findOne({ email: req.body.email });
        if (doesExist) return res.status(400).send("User already registered.");
        //if there isn't a user already registered with that email, we proceed to create the new user
        const user = new User(req.body)
        //hash the password to avoid saving it as plain text in the DB 
        //I'm using a package called bcrypt for this purpose
        user.password = await bcrypt.hash(req.body.password, 10);
        //save the user in the DB
        await user.save();
        res.status(200).json(`Welcome, ${user.firstName}`)
    } catch (error) {
        res.status(400).json(error)
    }
}
  • Now that the user exists in our database, we now have to provide a way for the user to log in. The following code samples are all wrapped up in an asynchronous function called "login"

    When users log in and provide valid credentials, they are sent a JWT (JSON Web Token).

        //store the user's inputted credentials
        const { email, password } = req.body;
        //check if a user with the inputted email exists
        const existingUser = await User.findOne({ email })
        if (!existingUser) { return res.status(400).json('There is no user registered under this email. Meant to register?')}
        //validate the given password and existing password
        //I'm using bcrypt for this purpose
        const validPassword = bcrypt.compareSync(password, existingUser!.password)
        if (!validPassword) { return res.status(400).json('Invalid Password')}
        //create the payload to be stored in the JWT
        const payload: UserAttributes = {
            _id: existingUser!._id,
            firstName: existingUser!.firstName,
            lastName: existingUser!.lastName,
            role: existingUser!.role,
            avatar: existingUser!.avatar
        }
        //create the JWT with the payload and a private key
        //I'm using a package called jwt for this purpose
        const userToken = jwt.sign(payload, process.env.PRIVATEKEY as string)
    

    A JWT is the industry's preffered method of sending payloads of encrypted data, often signed with a private key from the API. Authorization involves checking whether the user is holding a valid JWT. This JWT can be stored on the client's side in localStorage or as a cookie. localStorage and regular cookies may be more convenient to work with, but are both very prone to security issues. A malicious script running on the client side can very easily steal the data stored in cookies and localStorage — this is called an XSS attack.

    This is where HTTPOnly cookies come into play. HTTPOnly cookies are named as such because they are only accessible in HTTP requests and not by client-side JavaScript. This makes it annoying to work with at first — no script, however well-intentioned, that you write on the frontend can access the data stored in the cookie. These cookies are actually fairly simple to access in REST APIs.

        //create a new token with the JWT we just created
        res.cookie('auth-token', userToken, {
            //set it to expire in 2 weeks, or however long you want
            expires: new Date(new Date().getTime() + 60 * 60 * 24 * 7 * 1000),
            //marks the cookie to only work with HTTPS
            secure: true,
            //marks the cookie as HTTPOnly
            httpOnly: true
        }).status(200).json('Successfully logged in.')
    

    The cookie has been successfully sent to the user and stores the user's JWT, their access token, securely. Now the user can access routes that allow only authenticated users.

  • Now when the user visits other routes that may require more data than the JWT holds, they request the required data from the API. For this example, this route may be a dashboard of sorts that requires the user's doodles to render.

    This would require a middleware function in the API to check that the user is actually authenticated

    export const requiresAuth = async (req: Request, res: Response, next: NextFunction) => {
        //retrieve token
        const token = req.cookies['auth-token']
        //if there is no token on the client's side, they are not logged in, and cannot access the route.
        if(!token) return res.status(400).json('You are not logged in')
        try {
            //verify and decrypt the JWT from the token
            const payload: Object = jwt.verify(token, process.env.PRIVATEKEY as string)
            //save the user's information to be used for the route
            req.body.payload = payload
            //express function that moves to the next function in the route
            next()
        } catch (error) {
            res.status(400).json(error)
        }
    }
    

    Once the server can verify that the user is logged in, we can proceed to call a function that retrieves the doodles that said user posted. We can then send the doodles back to the user to be rendered on the frontend.

Conclusion

I am hardly an expert about authentication, security, and other best practices used by professionals. What I can truthfully claim, however, is that I now understand the fundamentals of authentication enough to implement it in any subsequent project without leaning on any third party platform and tailor it exactly to my needs.