codersteps logo

JWT Authentication in Next.js with API Routes and jose

Abdessamad Ely
Abdessamad Ely
Software Engineer
Reading time: 15 min read  •  Published: in Next.js  •  Updated:

In this article, I will share with you how to build a secure and production ready authentication system using JWT with Next.js API routes and jose.

Initializing a Next.js application

If you don’t already have a Next.js project, go ahead and create a new Next.js project using the app router.

To create a new Next.js project, navigate to your workspace then run npx create-next-app@latest nextjs_jwt_auth_system_with_api with all default options.

Once the create-next-app command is done, go ahead and open the project in your favourite code editor.

I always like to have a .prettierrc config file to have consistent formatting in all of my projects:

.prettierrc
{
  "semi": false,
  "tabWidth": 2,
  "singleQuote": true
}

Installing jose and other dependencies

Now that we created our project, let's install the dependencies we will need for this tutorial.

Core dependencies

jose is a popular npm dependency that we will use to sign and verify our JWT tokens. To install it let’s run npm i jose .

bcrypt is another popular npm dependency that we will use to hash and compare user's password before storing them to our database.

To install it let’s run npm i bcrypt, and because we're using TypeScript we need to also install its types with npm i -D @types/bcrypt.

Good to have dependencies

We will use the date-fns dependency to calculate the expiration date for the remember me feature.

We will use the zod dependency to validate the user input schema in the registration and login forms.

We will use the server-only dependency to protect server-only code on build-time.

To install them let's run the commands: npm i date-fns, npm i server-only, and npm i zod.

Create a Next.js Login page with Tailwind CSS

Under the app directory we will create a new dir (auth) to group all authentication related pages, then create the app/(auth)/login/page.tsx file:

app/(auth)/login/page.tsx
'use client'

import { FormEventHandler, useCallback, useState } from 'react'

export default function Login() {
  const [error, setError] = useState('')

  const onFormSubmit = useCallback<FormEventHandler<HTMLFormElement>>(
    async (e) => {
      e.preventDefault()
      const formData = new FormData(e.currentTarget)
      const res = await fetch('/api/login', {
        method: 'POST',
        body: formData,
      })

      if (!res.ok) {
        setError('Sorry! something went wrong.')
        return
      }

      const json = (await res.json()) as { success: boolean }
      if (!json.success) {
        setError('Invalid credentials.')
        return
      }

      location.href = '/dashboard'
    },
    []
  )

  return (
    <form className="pt-10" onSubmit={onFormSubmit}>
      <div className="w-96 mx-auto border border-gray-300 rounded-md space-y-3 px-6 py-8">
        <div className="space-y-5">
          <div className="pb-3">
            <h2 className="text-xl font-bold text-center">Login</h2>
          </div>
          <div className="space-y-1">
            <label htmlFor="username" className="text-sm font-bold select-none">
              Username
            </label>
            <input
              id="username"
              name="username"
              type="text"
              required
              tabIndex={1}
              placeholder="Username"
              className="block w-full text-sm p-3 bg-white border border-gray-300 placeholder:text-gray-400 focus:outline-none focus:border-gray-400 rounded"
            />
          </div>

          <div className="space-y-1">
            <label htmlFor="password" className="text-sm font-bold select-none">
              Password
            </label>
            <input
              id="password"
              name="password"
              type="password"
              required
              tabIndex={2}
              placeholder="Password"
              className="block w-full text-sm p-3 bg-white border border-gray-300 placeholder:text-gray-400 focus:outline-none focus:border-gray-400 rounded"
            />
          </div>

          <label className="inline-flex items-center space-x-1.5">
            <input type="checkbox" name="remember" tabIndex={3} />
            <span className="text-gray-500 text-xs leading-5 font-bold cursor-pointer select-none">
              Remember me
            </span>
          </label>

          <div className="flex justify-end">
            <button
              type="submit"
              tabIndex={4}
              className="bg-white h-10 border border-gray-400 text-gray-500 hover:border-gray-400 hover:text-black rounded text-sm font-medium px-3"
            >
              Log In
            </button>
          </div>

          {error && (
            <div className="font-medium text-xs text-red-500">{error}</div>
          )}
        </div>
      </div>
    </form>
  )
}

The login page, is straight forward, a simple React JSX with Tailwind CSS for styling and useState for tracking errors state.

We also implemented the onSubmit event to handle to handle the submit of our login form.

First, we create a formData object with the FormData class providing it our form, which automatically maps our form input values to the formData object.

Using the fetch client we make a POST request to the /api/login, which we will create later on in this article.

app/(auth)/login/page.tsx:snippet
const formData = new FormData(e.currentTarget)
const res = await fetch('/api/login', {
  method: 'POST',
  body: formData,
})

The fetch API gives us a response, which we use to check if there was any errors, like invalid credentials, or something broke on our server.

In case, there was an error we show it to the user with setError, otherwise if all went well we login the user and redirect him to the dashboard page.

app/(auth)/login/page.tsx:snippet
if (!res.ok) {
  setError('Sorry! something went wrong.')
  return
}

const json = (await res.json()) as { success: boolean }
if (!json.success) {
  setError('Invalid credentials.')
  return
}

location.href = '/dashboard'

We use the useCallback hook to avoid redefining the onFormSubmit handler on each render thus improving page performance.

Now, if you go to the login route at localhost:3000/login you should see the login form, but for now we're only focusing on pages design and client-side logic.

Create a Next.js Registration page with Tailwind CSS

Under the same folder (auth), let's create another route for our registration page, by creating a new file at app/(auth)/register/page.tsx:

app/(auth)/register/page.tsx
'use client'

import { FormEventHandler, useCallback, useState } from 'react'

export default function Register() {
  const [error, setError] = useState('')

  const onFormSubmit = useCallback<FormEventHandler<HTMLFormElement>>(
    async (e) => {
      e.preventDefault()
      const formData = new FormData(e.currentTarget)
      const res = await fetch('/api/register', {
        method: 'POST',
        body: formData,
      })

      if (!res.ok) {
        setError('Sorry! something went wrong.')
        return
      }

      const json = (await res.json()) as { success: boolean }
      if (!json.success) {
        setError('Invalid credentials.')
        return
      }

      location.href = '/login'
    },
    []
  )

  return (
    <form className="pt-10" onSubmit={onFormSubmit}>
      <div className="w-96 mx-auto border border-gray-300 rounded-md space-y-3 px-6 py-8">
        <div className="space-y-5">
          <div className="pb-3">
            <h2 className="text-xl font-bold text-center">Registration</h2>
          </div>
          <div className="space-y-1">
            <label htmlFor="username" className="text-sm font-bold select-none">
              Username
            </label>
            <input
              id="username"
              name="username"
              type="text"
              required
              tabIndex={1}
              placeholder="Username"
              className="block w-full text-sm p-3 bg-white border border-gray-300 placeholder:text-gray-400 focus:outline-none focus:border-gray-400 rounded"
            />
          </div>

          <div className="space-y-1">
            <label htmlFor="password" className="text-sm font-bold select-none">
              Password
            </label>
            <input
              id="password"
              name="password"
              type="password"
              required
              tabIndex={2}
              placeholder="Password"
              className="block w-full text-sm p-3 bg-white border border-gray-300 placeholder:text-gray-400 focus:outline-none focus:border-gray-400 rounded"
            />
          </div>

          <div className="flex justify-end">
            <button
              type="submit"
              tabIndex={3}
              className="bg-white h-10 border border-gray-400 text-gray-500 hover:border-gray-400 hover:text-black focus:outline-none focus:border-gray-400 rounded text-sm font-medium px-3"
            >
              Register
            </button>
          </div>
          {error && (
            <div className="font-medium text-xs text-red-500">{error}</div>
          )}
        </div>
      </div>
    </form>
  )
}

To make it simple, I kept the registration form similar to the login form, normally the registration will include more user information like Email, Name, etc..

You can use your own login/registration forms, the only important point is for you to understand how to use the login, and register API endpoints.

So, if you understand the onFormSubmit function that we use to handle our form submit, you can adapt both input fields, and error handling for your use-cases.

Similar to the login request, if the response was not ok, we show an error to the user, otherwise we do a redirect to the login page.

Later, we will see how we can use httpOnly cookie to login the user, so you can decide if you want to authenticate the user after registration or redirect him to the login page like we're doing here.

Now, if you go to the register route at localhost:3000/register you should see the registration form, but same as for login for now we only have the design and client-side logic.

Setup a dummy JSON based database

In case, you already have a real database ORM setup like prisma, drizzle ORM, or any other ORM of your choice, please:

  • Ignore this section as it's only for this article purpose.
  • Adapt the necessary part in the upcoming sections to your ORM.

To make it simpler, I decided to go with a simple JSON based database, so let's create our ORM to read/write our json file.

Let's start by creating a new file at lib/server/database.ts

lib/server/database.ts
import 'server-only'
import { join } from 'node:path'
import { readFile, writeFile } from 'node:fs/promises'

type User = {
  id: number
  username: string
  password: string
}

type Database = { users: User[] }

export type SafeUser = Omit<User, 'password'>

const databasePath = join(process.cwd(), 'lib/server/database.json')

async function readDatabase(): Promise<Database> {
  return JSON.parse(await readFile(databasePath, { encoding: 'utf8' }))
}

async function writeToDatabase(database: Database) {
  await writeFile(databasePath, JSON.stringify(database))
}

export async function getUserById(id: number): Promise<User | null> {
  const database = await readDatabase()
  return database.users.find((user) => user.id === id) || null
}

export async function getUserByUsername(
  username: string
): Promise<User | null> {
  const database = await readDatabase()
  return database.users.find((user) => user.username === username) || null
}

export async function saveNewUser(user: Omit<User, 'id'>) {
  const database = await readDatabase()
  const recentUser = database.users.slice(-1).pop()
  const id = recentUser ? recentUser.id + 1 : 1

  database.users.push({ id, ...user })

  await writeToDatabase(database)
}

We have two mean async functions readDatabase, and writeToDatabase to manage the content inside our database.json file.

Then we have three high-level functions getUserById, getUserByUsername, and saveNewUser to use in place of an ORM.

It's necessary to manually create the lib/server/database.json file with the following structure:

lib/server/database.json
{ "users": [] }

You may noticed that we also have a type type Database = { users: User[] } to model our database structure, as well as the type SafeUser to use when sending a user through API.

Create a Next.js Registration API Route

To create the registration route go ahead and create a new file at app/api/(auth)/register/route.ts

app/api/(auth)/register/route.ts
import { z } from 'zod'
import bcrypt from 'bcrypt'
import { saveNewUser } from '@/lib/server/database'

const schema = z.object({
  username: z.string(),
  password: z.string().min(3),
})

export async function POST(request: Request) {
  const formData = await request.formData()

  const parsed = schema.safeParse({
    username: formData.get('username'),
    password: formData.get('password'),
  })

  if (!parsed.success) {
    return Response.json({ success: false })
  }

  const { username, password } = parsed.data

  saveNewUser({
    username,
    password: bcrypt.hashSync(password, 10),
  })

  return Response.json({ success: true })
}

We started by defining a schema to validate the request body using the zod library.

Like mentioned previously, we're keeping it simple with only username and password, but you may have some more fields like: phone, email, etc..

After that we used our zod schema to validate the request payload, before saving it to the database like explained below:

Validating registration form request

app/api/(auth)/register/route.ts:snippet
const formData = await request.formData()

const parsed = schema.safeParse({
  username: formData.get('username'),
  password: formData.get('password'),
})

if (!parsed.success) {
  return Response.json({ success: false })
}

The schema.safeParse doesn’t throw an exception, instead it returns an object with a success property and a data property in case of success.

If the request includes invalid data, like short password or missing username. We return a response with success as false.

You can adapt the response to your project like returning a Bad Request with the 400 status, and maybe some custom error message.

Saving user to database

Once, the provided data is being validated, we use the saveNewUser we created in the previous section to save a new user to our database.

app/api/(auth)/register/route.ts:snippet
const { username, password } = parsed.data

saveNewUser({
  username,
  password: bcrypt.hashSync(password, 10),
})

return Response.json({ success: true })

For password, we can't store it in plain text, instead we need to hash it, to do that we're using the bcrypt.hashSync hash method.

Once the user is saved, we send back a json response with { success: true } which informs our registration form that the request was a success.

Create a Next.js Login API Route

Now that we can create users, let's create the login api so we will be able to login to our application.

Go ahead and create a new file at: app/api/(auth)/login/route.ts:

app/api/(auth)/login/route.ts
import { z } from 'zod'
import bcrypt from 'bcrypt'
import { addDays } from 'date-fns'
import { cookies } from 'next/headers'
import { signJWT } from '@/lib/server/jwt'
import { getUserByUsername } from '@/lib/server/database'

const schema = z.object({
  username: z.string(),
  password: z.string().min(3),
  remember: z
    .string()
    .transform((val) => val === 'on')
    .catch(false),
})

export async function POST(request: Request) {
  const formData = await request.formData()

  const parsed = schema.safeParse({
    username: formData.get('username'),
    password: formData.get('password'),
    remember: formData.get('remember'),
  })

  if (!parsed.success) {
    return Response.json({ success: false })
  }

  const { username, password, remember } = parsed.data

  const user = await getUserByUsername(username)
  if (!user || !bcrypt.compareSync(password, user.password)) {
    return Response.json({ success: false })
  }

  const token = await signJWT(
    { sub: `${user.id}` },
    { exp: remember ? '7d' : '1d' }
  )

  const cookieStore = await cookies()
  cookieStore.set('token', token, {
    path: '/',
    domain: process.env.APP_HOST || '',
    secure: true,
    expires: remember ? addDays(new Date(), 7) : addDays(new Date(), 1),
    httpOnly: true,
    sameSite: 'strict',
  })

  return Response.json({ success: true })
}

Same here, we started first by defining our zod schema to validate our request body, before trying to check if the user exists and check if the provided password is valid.

Let's break it down, so it's easy to understand why we have each part:

Validating login form request

app/api/(auth)/login/route.ts:snippet
const formData = await request.formData()

const parsed = schema.safeParse({
  username: formData.get('username'),
  password: formData.get('password'),
  remember: formData.get('remember'),
})

if (!parsed.success) {
  return Response.json({ success: false })
}

Here, we used the same method schema.safeParse to validate the request payload.

Similar to the registration route, if the request is invalid we send back a response with the success flag set to false.

Checking if the provided credentials are correct

app/api/(auth)/login/route.ts:snippet
const { username, password, remember } = parsed.data

const user = await getUserByUsername(username)
if (!user || !bcrypt.compareSync(password, user.password)) {
  return Response.json({ success: false })
}

After making sure the request contains both username, and password, its time that we use them to check if the user exists on our database or not.

If the user was found, we proceed to the next validation which is comparing if the password is the same as the one we have in hour database.

Because we hash the password before storing it in our database, we need to use the bcrypt.compareSync method which will take care of the comparison.

In case the user was not found, or the password doesn't match what we have in database, we send back a response with the success flag set to false.

JWT Token is a token that your application can use to identify the logged in user, this is secure thanks to a JWT Secret that normally is stored on your environment (.env).

app/api/(auth)/login/route.ts:snippet
const token = await signJWT(
  { sub: `${user.id}` },
  { exp: remember ? '7d' : '1d' }
)

const cookieStore = await cookies()
cookieStore.set('token', token, {
  path: '/',
  domain: process.env.APP_HOST || '',
  secure: true,
  expires: remember ? addDays(new Date(), 7) : addDays(new Date(), 1),
  httpOnly: true,
  sameSite: 'strict',
})

return Response.json({ success: true })

Now that we made sure that the provided credentials are correct, its time to login the user.

To do that, we first generate the token using the signJWT which is our helper around jose SignJWT class.

You may have noticed an error around this import import { signJWT } from '@/lib/server/jwt', this will be solved once we add the lib/server/jwt.ts file next.

Once we have the token, we store it as an HttpOnly cookie, so it's sent on every request, but can’t be accessible by Javascript, which is kind of a protection against cross-site scripting.

Both JWT Tokens and Cookies have an expiration date, and using the Remember Me flag we decide if we want them to expire after 1 day or a 1 week.

And finally, we send a response with success flag set to true.

Creating the JWT and Env helpers

Instead of directly using jose in the authentication flows, we created wrappers around them, this is will help us organize, reuse, and refactor later on.

Go ahead and create a new file at lib/server/jwt.ts, with the following:

lib/server/jwt.ts
import 'server-only'
import { getEnvVariable } from '@/lib/env'
import { jwtVerify, SignJWT, JWTPayload } from 'jose'

export function signJWT(
  payload: { sub: string },
  options: { exp: string }
): Promise<string> {
  try {
    const alg = 'HS256'
    const secret = new TextEncoder().encode(getEnvVariable('JWT_SECRET'))

    return new SignJWT(payload)
      .setProtectedHeader({ alg })
      .setExpirationTime(options.exp)
      .setIssuedAt()
      .setSubject(payload.sub)
      .sign(secret)
  } catch (error) {
    console.error("Wasn't able to sign the token.", error)
    throw new Error("Wasn't able to sign the token.")
  }
}

export async function verifyJWT(token: string): Promise<JWTPayload | false> {
  const secret = new TextEncoder().encode(getEnvVariable('JWT_SECRET'))
  if (!token) {
    return false
  }

  try {
    const jwt = await jwtVerify(token, secret)
    return jwt.payload
  } catch (error) {
    if (error instanceof Error) {
      console.log('verifyJWT:', error.message)
    }
    return false
  }
}

It's straight forwards, we use SignJWT to sign our JWT Token, and jwtVerify to verify if it's valid using the same secret JWT_SECRET defined in .env file.

If something is not clear please consider checking jose documentation for SignJWT and jwtVerify.

We also made use of the getEnvVariable function which is defined inside lib/env.ts, which is just another helper to read our .env environment variables.

Let's create it by creating a new file at lib/env.ts, then explain how it works:

lib/env.ts
type EnvVariableKey = 'JWT_SECRET'

export function getEnvVariable(key: EnvVariableKey): string {
  const value = process.env[key]

  if (!value || value.length === 0) {
    console.error(`The environment variable ${key} is not set.`)
    throw new Error(`The environment variable ${key} is not set.`)
  }

  return value
}

First, we define the EnvVariableKey type for variables we have inside our .env file, to help us avoid typos with help of TypeScript.

Create .env file with JWT_SECRET

Now, that we have created the login api, for it to work we need to define the JWT_SECRET inside our .env file.

Lets create a new file .env.local at the root of the project like:

.env.local
JWT_SECRET="secret_of_32_hexadecimal_and_symbols"

We need to keep the JWT_SECRET secure, as well as make it hard to crack, that's why I prefer using a password generator tool to generate it.

Now, you can go ahead, use the /register page to create a new user, then user the /login page to login with newly created user.

You should be able to login and get redirected to the /dashboard page which will be creating and protecting next.

You will also be able to see that we have stored our JWT Token correctly on browser as an HttpOnly cookie:

JWT Token Stored in HttpOnly Cookie
JWT Token Stored in HttpOnly Cookie

Share the Authenticated User via React Context

To make the authenticated user available to our application we will create a new API /api/profile, then use it inside a React Context Provider.

Creating the Auth helpers

To make it easy for us to get the auth user, check if the user is authenticated or unauthenticated, we’ll create some helpers at lib/server/auth.ts:

lib/server/auth.ts
import 'server-only'
import { verifyJWT } from './jwt'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { getUserById, SafeUser } from './database'

/**
 * Redirect when a user is unauthenticated
 */
export async function checkUnAuthenticated(url: string = '/') {
  const cookieStore = await cookies()
  const token = cookieStore.get('token')

  if (!token || !token.value) {
    redirect(url)
  }

  const jwtPayload = await verifyJWT(token.value)
  if (jwtPayload === false) {
    redirect(url)
  }
}

/**
 * Redirect when a user is authenticated
 */
export async function checkAuthenticated(url: string = '/login') {
  const cookieStore = await cookies()
  const token = cookieStore.get('token')

  if (token && token.value) {
    const jwtPayload = await verifyJWT(token.value)
    if (jwtPayload !== false) {
      redirect(url)
    }
  }
}

/**
 * Get authenticated user from cookie token
 */
export async function getAuthenticatedUser(): Promise<SafeUser | null> {
  const cookieStore = await cookies()
  const token = cookieStore.get('token')

  if (!token || !token.value) {
    return null
  }

  const jwtPayload = await verifyJWT(token.value)
  if (jwtPayload === false || !jwtPayload.sub) {
    return null
  }

  return getUserById(+jwtPayload.sub) || null
}

The 3 helpers are straight forward, and kind of uses similar principles:

  1. **checkUnAuthenticated**: if the user is not logged in, redirect the user to the provided page URL.
    • User is not authenticated if there's no JWT Token or it's invalid.
  2. **checkAuthenticated**: if the user is logged in, redirect the user to the provided page URL.
    • User is authenticated if there's a valid JWT Token.
  3. **getAuthenticatedUser**: if the JWT Token is valid, it will the `jwtPayload.sub` to get the user information.

Create a Next.js Profile API Route

After creating the auth helpers, let's use the getAuthenticatedUser function to retrieve the authenticated user and return it as a response for our profile API.

Go ahead and create a new file at app/api/(auth)/profile/route.ts with the following:

app/api/(auth)/profile/route.ts
import { cookies } from 'next/headers'
import { getAuthenticatedUser } from '@/lib/server/auth'

export async function GET() {
  const user = await getAuthenticatedUser()

  if (!user) {
    /** Remove expired token from the user's browser  */
    const cookieStore = await cookies()
    cookieStore.set('token', '', {
      path: '/',
      domain: process.env.APP_HOST || '',
      secure: true,
      expires: new Date('0000'),
      httpOnly: true,
      sameSite: 'strict',
    })

    return Response.json({
      authUser: null,
    })
  }

  return Response.json({
    authUser: { ...user, password: undefined },
  })
}

Because we’re using this API on every page refresh, we also included logic to remove the expired JWT token in case it was tampered with.

If the user is authenticated, then we respond with the authUser, excluding sensitive information like the password.

Store the Authenticated User in React Context

TODO

Create a Next.js Dashboard Layout to Protect Authenticated Pages

TODO

Create a Next.js Guest Layout to Protect Guest Pages

TODO

Abdessamad Ely
Abdessamad Ely
Software Engineer

A web developer from Morocco Morocco flag , and the founder of coderstep.com a website that help developers grow and improve their skills with a set of step-by-step articles that just works.

Your Questions

  • No questions have been asked yet.

Submit your question

Please use the form bellow to submit your question, and it will be answered as soon as possible.