Password-Based Authentication in Next.js

Category
Guides
Published

This article explores password authentication, risks, and better solutions like SSO, MFA, and passwordless login.

Passwords. The best and worst thing that ever happened to internet security. We talk about passwordless and SSO and magic links and MFA, but 98% of the world’s websites only accept password authentication.

It is expected that a site will allow you to enter your own username/email and a password to sign up and log in. Even though we know there are security flaws with this approach and much better ways to build authentication, your site has to have password-based authentication, otherwise it looks… odd.

So how do you do that? Here we’re going to show you. But this is a demonstration of the internal workings of password authentication to give you an understanding of how it works. This isn’t something you should truly build yourself if you care about your users. Use libraries and services built by experts if you are going to implement password authentication in your app–we show you how to do this with Clerk at the end.

Building passwords from basics in Next.js

You need four components to build password authentication:

  1. A frontend where the user can enter their username and password to sign up or log in
  2. A backend to deal with the signup/login logic
  3. A way to hash the password so it can be stored securely
  4. A storage mechanism (and logic) to save the username and hashed password

Parts 1-3 are are possible with Next.js. For storage, we’ll be using Supabase. Supabase is an open source Firebase alternative that provides a large selection of database management tools. It integrates with a large number of modern SaaS tools, including Clerk. Here, we won’t be using the Supabase libraries. Instead, as under the hood Supabase is a Postgres database, we’ll use the low-level methods to connect and then use SQL to load and recall our data.

Let’s start with creating a new Next.js application:

npx create-next-app@latest password-auth
cd password-auth

We’ll only be using two extra libraries in this build:

  • bcrypt: Bcrypt is a password hashing function which incorporates a salt to protect against rainbow table attacks.
  • pg: Pg (short for “node-postgres”) is a collection of Node.js modules for interfacing with PostgreSQL databases, offering features such as connection pooling and prepared statements.

You can install them with npm install:

npm install bcrypt pg

Storage

We’ll actually work backwards from our list above and start with our database functionality. As we said, we’re using Supabase. Supabase is an open-source Firebase alternative that provides developers with a suite of tools and services for building serverless applications. It offers real-time databases, instant APIs, and authentication and authorization functionalities.

Here, we’re only going to use the database component, and even then we won’t use the Supabase libraries. Instead, we’ll just use the general Postgres account information to connect directly to the database.

Sign up for Supabase and create a new project. The project can be called anything, but take note of the password you create as you will need that for the connection. The project will take a few minutes to spool up, but once it has, go to “Database → Table → new table” to create a new table. You’ll only need two fields in this table:

  • Username - Text
  • Password - Text

You can keep the id and created_at fields. Make sure, for the purposes of this demo at least, you turn Row Level Security off as otherwise it’ll be much more difficult to access the database.

Row-Level Security (RLS) is a feature that allows fine-grained control over which rows in a table can be accessed and modified by users. With RLS, the database admin can define policies that restrict access to certain rows based on the attributes of the user or the data. If you are building for production, RLS is a must.

With the table created, head to Project Settings -> Database. You’ll see “Connection info.” This is what we’ll need to connect to this database:

With that information (and the password you created earlier), we can now start building the code and logic for our password authentication.

Open up your password-app directory in your favorite IDE and add a “utils” directory at the root. Within utils, create a db.js file that will handle your database connection:

import { Pool } from 'pg'

const pool = new Pool({
  user: 'User',
  host: 'Host',
  database: 'Database name',
  password: 'Password',
  port: 5432,
})

export default pool

Fill in the User, Host, Database name, and Password with the “Connection info” from Supabase.

(Like with RLS, don’t do it this way if you are deploying to production–never hardcode variables like this in your code. Instead save them as environmental variables and import them via process.env).

Hashing and salting

With our database connection sorted, we can start to create the backend logic we need to route our user requests and deal with the username and password.

There should already be a pages/api directory. Within that create a signup.js file. Add this code to the file:

import pool from '../../utils/db'
import bcrypt from 'bcrypt'

export default async function signup(req, res) {
  if (req.method === 'POST') {
    const { username, password } = req.body

    try {
      // Hash the password
      const hashedPassword = await bcrypt.hash(password, 10)

      // Store the username and hashed password in the database
      const result = await pool.query('INSERT INTO users(username, password) VALUES($1, $2) RETURNING *', [
        username,
        hashedPassword,
      ])

      // If user is created successfully, return a success message
      res.status(201).json({ status: 'Created', user: result.rows[0] })
    } catch (error) {
      res.status(500).json({ status: 'Error', message: error.message })
    }
  } else {
    res.status(405).json({ status: 'Method Not Allowed' })
  }
}

Here’s a breakdown of what the code does:

  • import pool from ‘../../db’ imports the pool object from db.js which is a pool of database connections we’re using to connect to Postgres
  • import bcrypt from ‘bcrypt’ imports the bcrypt library, which is a password-hashing function. You’ll use it to securely hash passwords before storing them in the database.
  • export default async function signup defines an asynchronous function called signup which is the main function of this module.
  • if (req.method === ‘POST’) checks if the HTTP request method is POST. If it’s not, it returns a 405 status code.
  • const { username, password } = req.body destructures the request body to extract the username and password properties from the request body.
  • const hashedPassword = await bcrypt.hash(password, 10) uses bcrypt to hash the user’s password asynchronously with a salt round of 10. The hashed password is then stored in hashedPassword.
  • const result = await pool.query(…) sends a SQL query to the Supabase to insert a new row into the users table. It inserts the username and the hashed password.
  • ’INSERT INTO users(username, password) VALUES($1, $2) RETURNING *’ is the SQL query being sent to the database. It’s parameterized to prevent SQL injection attacks. The $1 and $2 are placeholders for the username and hashedPassword that will be inserted.
  • The final lines are logic for catching errors or returning success messages.

There’s a lot there, but basically when this API route is called it takes the username and password the user entered, hashes and salts the password, and then saves them in the database.

What are hashing and salting?

  • Hashing: Hashing is the process of converting an input of any length into a fixed size string of text, using a mathematical algorithm. Hashing is often used to securely store sensitive data such as passwords. Even a small change in the input text will produce a drastic change in the output hash, making it computationally infeasible to derive the original input from the hashed output.
  • Salting: Salting is a technique used in conjunction with hashing to increase the security of stored passwords. A salt is a random piece of data generated for each user that is added to the password before it is hashed. This means that even if two users have the same password, their hashed passwords will be different because the salts are different. Salting helps protect against rainbow table attacks, where an attacker pre-computes the hash values for possible passwords and looks for matches with hashed passwords.

All that happens in the line const hashedPassword = await bcrypt.hash(password, 10). Doing this is fundamental to password security. Without it, passwords would be saved in plain text, and anyone having access to the database would be able to retrieve everyone’s passwords. With hashing and salting, what is saved in the database isn’t the raw password.

Now that we can sign up, let’s create a file called pages/api/login.js so we can also log in. In this file, you would fetch the user from the database, compare the hashed passwords, and if they match, return a success:

import pool from '../../utils/db'
import bcrypt from 'bcrypt'

export default async function login(req, res) {
  if (req.method === 'POST') {
    const { username, password } = req.body

    try {
      // Get the user with the provided username
      const user = await pool.query('SELECT * FROM users WHERE username = $1', [username])

      if (user.rows.length > 0) {
        const passwordMatches = await bcrypt.compare(password, user.rows[0].password)

        if (passwordMatches) {
          // Passwords match, return a success message
          res.status(200).json({ status: 'Success', message: 'Login successful' })
        } else {
          // Passwords don't match, return an error message
          res.status(403).json({ status: 'Error', message: 'Invalid password' })
        }
      } else {
        res.status(404).json({ status: 'Error', message: 'User not found' })
      }
    } catch (error) {
      res.status(500).json({ status: 'Error', message: error.message })
    }
  } else {
    res.status(405).json({ status: 'Method Not Allowed' })
  }
}

This is very similar to the previous route, but instead of hashing and then storing, we’re retrieving and then comparing the stored hash to a hash of the password the user tried with the login page.

We retrieve the saved password from Supabase with const user = await pool.query(‘SELECT * FROM users WHERE username = $1’, [username]). This grabs the user with the username just entered by the user.

We then use const passwordMatches = await bcrypt.compare(password, user.rows[0].password) to compare the entered password with the retrieved password. bcrypt.compare is a function that takes a plain-text input and a hashed string as arguments, and returns true if the plain-text input, when hashed with the same salt as the hashed string, matches the hashed string, thus verifying the authenticity of the input.

Creating the frontend with Next.js

All we need now is some logic on the frontend for the user to interact with. First, we’ll create a Profile component in components/profile.jsx:

import React from 'react'

const Profile = ({ user }) => {
  return (
    <div>
      <h1>Your Profile</h1>
      <p>Welcome, {user.username}!</p>
    </div>
  )
}

export default Profile

In this component, we simply display a welcome message with the user’s username. Then we’ll remove the current Next.js boilerplate from index.js and add this code to include a login/signup form and the profile display.

import React, { useState } from 'react'

const AuthForm = () => {
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [isLoggedIn, setIsLoggedIn] = useState(false)
  const [user, setUser] = useState(null)

  const handleSubmit = async (e, path) => {
    e.preventDefault()
    const response = await fetch(`/api/${path}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username, password }),
    })
    if (response.ok) {
      const user = await response.json()
      setUser(user)
      setIsLoggedIn(true)
    } else {
      console.log(`${path} failed`)
    }
  }

  return (
    <div>
      {!isLoggedIn ? (
        <form>
          <label>
            Username:
            <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} />
          </label>
          <label>
            Password:
            <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
          </label>
          <button type="submit" onClick={(e) => handleSubmit(e, 'signup')}>
            Signup
          </button>
          <button type="submit" onClick={(e) => handleSubmit(e, 'login')}>
            Login
          </button>
        </form>
      ) : (
        <Profile user={user} />
      )}
    </div>
  )
}

export default AuthForm

In this component, we have a form with fields for username and password, and two buttons for signup and login. When either button is clicked, the handleSubmit function is called with the corresponding path. If the request is successful, we update the isLoggedIn and user states, which will cause the Profile component to be rendered.

So the user sees this (very simple) form:

Clerk Login

If they sign up/log in correctly, they get to their profile page:

Clerk Profile

On the backend, we can see the new user in our database in Supabase, with their hashed and salted password:

Hashed Password

Extra security

This is a basic example and does not handle all the edge cases you might need to cover in a production app, such as:

  • Checking for unique usernames or emails during signup. For instance, the hashing and database retrieval depends on unique usernames.
  • Handling password resets.
  • Adding email verification.

You can check whether a username exists by running an additional query on the database before you try and select the user:

export default async function signup(req, res) {
  if (req.method === 'POST') {
    const { username, password } = req.body

    try {
      // Check if a user with the same username already exists
      const userExists = await pool.query('SELECT username FROM users WHERE username = $1', [username])

      if (userExists.rows.length > 0) {
        res.status(409).json({ status: 'Error', message: 'Username already exists' })
        return
      }

      // Hash the password
      const hashedPassword = await bcrypt.hash(password, 10)

      // Store the username and hashed password in the database
      const result = await pool.query('INSERT INTO users(username, password) VALUES($1, $2) RETURNING *', [
        username,
        hashedPassword,
      ])

      // If user is created successfully, return a success message
      res.status(201).json({ status: 'Created', user: result.rows[0] })
    } catch (error) {
      res.status(500).json({ status: 'Error', message: error.message })
    }
  } else {
    res.status(405).json({ status: 'Method Not Allowed' })
  }
}

Here, you’ll get an error message if the user already exists.

Handling password resets and email verification are much more difficult. To handle password resets, you typically need to do the following:

  • Generate a unique token for the password reset request.
  • Associate this token with the user in your database, and set an expiry time for it.
  • Send an email to the user with a link containing this token.
  • When the user clicks the link, verify the token and its expiry time.
  • If the token is valid, allow the user to enter a new password.

Email verification is similar, but instead of adding a new password at the end, you’ll have a verified boolean on the user that you set to true.

We also haven’t added any ability to force stronger passwords on users. You can do that through using a library such as the validator library. The validator library provides a collection of string validation and sanitization methods, simplifying data validation tasks in the server side, client side, or even for data stored in the database:

import bcrypt from 'bcrypt'
import validator from 'validator'
import pool from '../../utils/db'

export default async function signup(req, res) {
  if (req.method === 'POST') {
    const { username, password } = req.body

    // Validate the password
    if (
      !validator.isStrongPassword(password, {
        minLength: 8,
        minLowercase: 1,
        minUppercase: 1,
        minNumbers: 1,
        minSymbols: 1,
        returnScore: false,
      })
    ) {
      res.status(400).json({ status: 'Error', message: 'Password does not meet complexity requirements' })
      return
    }

    try {
      const hashedPassword = await bcrypt.hash(password, 10)
      const result = await pool.query('INSERT INTO users(username, password) VALUES($1, $2) RETURNING *', [
        username,
        hashedPassword,
      ])

      res.status(201).json({ status: 'Created', user: result.rows[0] })
    } catch (error) {
      res.status(500).json({ status: 'Error', message: error.message })
    }
  } else {
    res.status(405).json({ status: 'Method Not Allowed' })
  }
}

Here we can set that the password must have eight characters and one uppercase letter, one lowercase, one number, and one symbol.

There is a lot to think about to just do the basics of password authentication. Don’t want to do all that?

Using Clerk with Next.js for password authentication

Clerk allows you to quickly and easily add password authentication to Next.js and has both client and server side components.

Before we get to the code, we first want to set up our application to use email and password authentication in the Clerk dashboard. Head to your dashboard and select “Email, Phone, Username” under the “User & Authentication” menu. Make sure email is selected:

Clerk User & Authentication Menu

You can see you can make this required and used for sign-in, which is what we want here. We can also, with a quick toggle rather than dozens of lines of JavaScript, say that we want email verification.

Then, in the same menu, find the “Authentication factors” options and select “Password.” Again here you can easily select to force the user to use a more secure password:

Authentication Factors

Now we can start with the code. Let’s create a new Next.js app:

npx create-next-app@latest clerk-auth
cd clerk-auth

Now we can install Clerk:

npm install @clerk/nextjs

Next, we want to create our environment variables for our Clerk API keys and routes. Create a .env.local file in the root directory and add your keys and these routes (that we’re going to create in a moment):

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_****
CLERK_SECRET_KEY=sk_test_****
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/signin
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/signup
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/

With the keys and routes in place, we can add wrapper. This provides an active session and user context to Clerk’s hooks and other components. We want to wrap the entire to enable the context to be accessible anywhere within the app, so we put it in our _app.jsx file:

import { ClerkProvider } from "@clerk/nextjs";
import type { AppProps } from "next/app";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ClerkProvider {...pageProps}>
      <Component {...pageProps} />
    </ClerkProvider>
  );
}

export default MyApp;

We’re also going to add some middleware, which is the function that decides which pages are protected. Here, we’re going to protect everything. Add a middleware.js file to the root directory with this code:

import { authMiddleware } from '@clerk/nextjs'

// This example protects all routes including api/trpc routes
// Please edit this to allow other routes to be public as needed.
// See https://clerk.com/docs/nextjs/middleware for more information about configuring your middleware
export default authMiddleware({})

export const config = {
  matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
}

We’ll now create our sign up and sign in pages. To create the sign up page, create a file at page/signup/[[…index]].jsx:

import { SignUp } from '@clerk/nextjs'

export default function Page() {
  return <SignUp />
}

The [[…index]] syntax defines a catch-all route, so anything the user entered under signup (e.g. signup/a or signup/b) will still go to this page.

We’ll do the same for signing in, with the page at page/signin/[[…index]].jsx:

import { SignIn } from '@clerk/nextjs'

export default function Page() {
  return <SignIn />
}

Finally, all we need is a button on the client to sign up and log in:

// pages/index.jsx
import { UserButton } from '@clerk/nextjs'

export default function Example() {
  return (
    <>
      <header>
        <UserButton afterSignOutUrl="/" />
      </header>
      <div>Your page's content can go here.</div>
    </>
  )
}

Now when we npm run dev, we’ll get the Clerk sign in modal:

Clerk Sign In

As we don’t have an account, we’ll switch to the sign up option:

Create Account

We can then enter our email address and password. We have email verification turned on, so we have to go and click the link in our email. After that we’ll be redirected to our content:

Next.js Content

Easier passwords in Next.js

That’s it. You now have password authentication set up with the necessary security features. Clerk offers custom password flows so you can design the sign in flow for your app as you want

People expect passwords on their sites. They aren’t going anywhere soon. So if you are creating a new site and want password authentication, use a well-designed library or service rather than creating your own–you and your users will be grateful.

Author
Nick Parsons