HTTP-Only Cookies Authentication with Next.js

HTTP-Only Cookies Authentication with Next.js

Greeting! folks, If you don't want to save your JWT token on the client side then I believe that's why you're here, So welcome. In this article am going to show you how to handle authentication, JWT & HttpOnly Cookies with Next.js. This example will go with any API endpoints you are using. In order to follow along with the project, I create a starter project where all the styling is done so we can just focus on handling all the required Authentications in our application. You can grab it here Starter files

What We're Going To Cover

  • What Is JWT?
  • JSON Web Token Dos and Don't
  • Our JWT Strategy Explained
  • Cloning And Setting Up Our Project
  • Setting Up AuthContext
  • Login And Get JWT
  • Store JWT In Server HttpOnly Cookie
  • Persist Logged in User
  • Logout And Destroy Cookie
  • Register User

What Is JWT?

A JSON Web Token (JWT) is really just a way to transmit information between two parties. One party might be your frontend React application and another party might be your API. The real value of JSON Web Tokens is they include a security feature. That is you can be sure that the information that was transmitted in the token wasn't tampered with along the way.

JSON Web Token Dos And Don't

I wanted to outline some do's and don'ts. Let's start with the don'ts.

  • The first don't that I've got is don't store your tokens in Local Storage. The reason that it's risky to keep them in local storage is that local storage is easily scriptable.
  • The next don't that I've got is don't keep these secret keys that go into signing your tokens in the browser. The only place that you should be keeping your secret keys is on your backend, because browsers are public clients. Any time a user loads up a website or an application, they get all of the code that goes into powering that application.
Now for the Do's,

Do keep long, strong, unguessable secrets. Keep something that is super long, strong, and unguessable.

Our JWT Strategy Explained

Now what we are going to do in our application is create an API routes within Nextjs which run on the server-side. We are going to have routes that we can hit, then from there we will make our request to the API Endpoint, get the token and then we will set the cookie on the server-side, what's called the Http-Only Cookie, that means it can't be accessed via the browser (local Storage) so that's a saver way to go. So let's dive into code and start to create our API routes.

Cloning And Setting Up Our Project

So like l said I have already created a starter files so Jump right in and clone it.

https://github.com/calebbenjin/starter-jwtauth-nextjs

After cloning the app, open it in your VScode and press crtl+J your terminal will open then type yarn add or npm install to install all necessary dependences. After that type npm run dev or yarn run dev: Your app will look this

Setting Up AuthContext

Now we want to create our context, we are going to use the Context-API, where we can store all our Authentication methods, our users and also any errors that comes from authentication. So we are going to create a new folder in the root called context then inside the context we're going to create a file called AuthContext.js.

your application should look like this

So We want to basically create a context using createContext from react. So now go inside your AuthContext file and fill it with this code snippet below.

import { useState, useEffect, createContext } from 'react'
import { useRouter } from 'next/router'
import {NEXT_URL} from '../config/index'

const AuthContext = createContext()

export const AuthProvider = ({children}) => {
  const [user, setUser] = useState(null)
  const [error, setError] = useState(null)
  const [isLoading, setIsLoading] = useState(false)

  const router = useRouter()

  // Register user
  const register = async ({ fullname, email, password }) => {
    setIsLoading(true)
    console.log(fullname, email, password)
  }

  // Login user
const login = async ({email, password}) => {
  setIsLoading(true)
  console.log(email, password)
}

  // Logout user
  const logout = () => {
    console.log("User Logged out")
  }

  // Check if user id Logged in
  const checkedUserLoggedIn = async (user) => {
    console.log('Checked')
  }

  return (
    <AuthContext.Provider value={{ register, login, logout, isLoading, user, error}}>
      {children}
    </AuthContext.Provider>
  )
}


export default AuthContext

Now let me explain the code above. We imported some necessary hooks from react like { useState, useEffect, createContext } and also {useRouter} from next/router, Next we imported our {API_URL} this will be your API endpoint URL of choice. Next we create a context by creating a variable called AuthContext and set it to createContext. Next we created a provider that needs to wrap around our application so we can provides certain functions to our application and whatever component needed. Next we created some state [user, setUser] and [error, setError] and we set the default to null. Next we created some methods like register, login, logout, checkUserLoggedIn which we will use to hit our backend routes. Then as you can see we are exposing all the methods created so it can be accessible all over the application. So let's do that by going into our _app.js file in the pages folder and bring in our AuthProvider as you can see below.

import '../styles/globals.css'
import Navbar from '../components/Navbar'
import {AuthProvider} from '../context/AuthContext'

function MyApp({ Component, pageProps }) {
  return (
    <AuthProvider>
      <Navbar />
      <Component {...pageProps} />
    </AuthProvider>
  )
}

export default MyApp

Login & Get JWT

So in this section we are going to setup our login functionality and get the JWT token, we're not going to store it just yet but what we want to do is to create an api-route to connect to and in that api-route is were we are going to communicate with our backend-endpoint, we are going to send our request from there get the token and then our next step is to save the Http-Only Cookie. So let's dive right in by getting into our api folder and create a new file called login.js api-route folder Now copy the code below and paste in the login.js file you have created, I will explain things in details below.

import { API_URL} from '../config/index'

export default async (req, res) => {
  if(req.method === 'POST') {

  } else {
    res.setHeader('Allow', ['POST'])
    res.status(405).json({message: `Method ${req.method} not allowed`})
  }
}

First we import our API_URL this can be your api url of choice Next we create an async function and pass in our (req res) Next we want to make sure if is the req.method is equal to POST, else we want to res.setHeader('Allow', ['POST'] and set the status res.status(405) which is method not allowed and send a .json({message:Method ${req.method} not allowed}). Next after making sure is a post request we want to get the email, and password from the req.body so we do that by destructuring the email and password from req.body. Now in this our api route this were we want to login our user with actual backend api-endpoint or l should say fetch our token. Now go ahead and paste the code below inside of your code.

   // destructure email, and password
    const { email, password } = req.body

    // Making a post request to hit our backend api-endpoint
    const apiRes = await fetch(`${API_URL}/your url of choice`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        email,
        password
      })
    })

    const data = await apiRes.json()

    if(apiRes.ok) {
      // @todo - Set Cookie

      res.status(200).json({user: data.user})
    } else {
      res.status(data.statusCode).json({message: data.message})
    }

So if you're following correctly your code show look like this below.

import { API_URL} from '../config/index'

export default async (req, res) => {
  if(req.method === 'POST') {
    const { email, password } = req.body

    const apiRes = await fetch(`${API_URL}/your url of choice`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        email,
        password
      })
    })

    const data = await apiRes.json()

    console.log(data.jwt)

    if(apiRes.ok) {
      res.status(200).json({user: data.user})
    } else {
      res.status(data.statusCode).json({message: data.message})
    }

  } else {
    res.setHeader('Allow', ['POST'])
    res.status(405).json({message: `Method ${req.method} not allowed`})
  }
}

So what we have done so far, which is creating this api-endpoint inside our Nextjs app, is like a middle man between our frontend and the backend-api, and then we are doing this so we can set Http-Only Cookie with token. You can console.log(data.jwt) to see it. Next let's head over to AuthContext and go to the login method we create so we can make a request to our api/login api-endpoint we have created. So paste these code inside of the login function.

const res = await fetch(`${NEXT_URL}/api/login`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      email,
      password
    })
  })

  const data = await res.json()

  if(res.ok) {
    setUser(data.user)
    router.push('/dashboard')
  } else {
    setError(data.message)
    setError(null)
  }

Now we are fetching the data from the api route we create in api/login. After that we check if the request is okay then we setUser(data.user) and make a redirect to our dashboard using next/router, But if is not Ok then we want to setError(data.message) and also setError(null) so the error will not remain in our state. Next let's head on to our login page and bring in our login method from AuthProvider, so now update your login page with these code


import AuthContext from '../context/AuthContext'

  const { login, error, user, isLoading } = useContext(AuthContext)

  const handleLoginSubmit = async ({ email, password }) => {
    login({email, password})
  }

We are importing our AuthContext, then we destructure out login, error, user, isLoading from it. Then in our handleLoginSubmit function we then call in the login({email, password}) and then pass in email, and password. Now at this point our app should be working very fine, next we are going to go head and store our jwt in the server httpOnly Cookie. Let's dive in.

Now what we want to do is set the Cookies, there's quite a few ways to do this, but we are going to use a package called cookie that let's us easily set cookie on the server-side, if you check in our package.json file you will see that l have install it already, or you can install it @ yard add cookie or npm install cookie if you are not using the start file. Next we going to bring in our api/login.js file

import cookie from 'cookie'

So go down the code where we have our @todo Set Cookie comment and add these code there.

  res.setHeader(
    'Set-Cookie',
    cookie.serialize('token', String(apiRes.data.token), {
      httpOnly: true,
      secure: process.env.NODE_ENV !== 'development',
      maxAge: 60 * 60 * 24 * 7, // 1 week
      sameSite: 'strict',
      path: '/'
    })
 )

Now as you can see we are setting res.setHeader that's coming with 'Set-Cookie' and a second parameter of cookie.serialize() then we set the name of the cookie to be cookie.serialize('token') and the value is going to be cookie.serialize('token', String(apiRes.data.token) and we also have an object option which is the httpOnly: true and secure since is going to be https and we want that to be true on production not development then we are going to set it to process.env.NODE_ENV !== 'development', and also check the node environment and see if that's not equal to development if is equal to development then is going to be false, if is in production is going to be true. Then we do maxAge is set to a week maxAge: 60 * 60 * 24 * 7, // 1 week. then we set sameSite to strict and path is set to '/' because we want it to be accessible everywhere. So this will set the cookie on the server-side once we login our app.

Persist Logged in User

Now we are going to persist the user and that is going to happen with the checkUserLoggedIn function we created in our AuthContext. Now this checkUserLoggedIn is going to hit a new route called user so go ahead and create a user.js file inside of our api folder. Basically what we are going to do in this user.js is to hit the users endpoint of your api, what we can do is we can send our token which we have in our cookie right now, once we send the token it will give you back the user for that token, then what we do with in AuthContext is set the user. Now go head and copy the code and paste in the user.js file you have created.

import { API_URL } from '@/lib/index'
import cookie from 'cookie'

export default = async (req, res) => {
  if (req.method === 'GET') {
    if (!req.headers.cookie) {
      res.status(403).json({message: 'Not Authorized'})
      return
    }

    const { token } = cookie.parse(req.headers.cookie)

    const apiRes = await fetch(`${API_URL}/user`, {
      method: 'GET',
      headers: {
        Authorization: `Bearer ${token}`
      }
    })

    const user = await apiRes.json()

    if(apiRes.ok) {
      res.status(200).json({user})
    } else {
      res.status(403).json({message: 'User forbidden'})
    }
  } else {
    res.setHeader('Allow', ['POST'])
    res.status(405).json({ message: `Method ${req.method} not allowed` })
  }
}


export default user

Now inside our function we are first checking to see if the cookie exist (!req.headers.cookie) if that's not there then res.status(403).json({message: 'Not Authorized'}) and then we return. But if is found then we need to pass the cookie and get the token. we then destructure the token const { token } = cookie.parse(req.headers.cookie) this will put the token into a variable and then we can send into our backend-Api. Once we get the user back. and then check if the apiRes.ok then we want to set the status(200) and send the user object. else the user is forbidden res.status(403).json({message: 'User forbidden'}). Now let's save that and hit this api-route with checkUserLoggedIn. now let's go to our AuthContext and fill in out checkUserLoggedIn with this code, just a simple get request

 const checkUserLoggedIn = async () => {
    const res = await fetch(`${NEXT_URL}/api/user`)
    const data = await res.json()

    if (res.ok) {
      setUser(data.user.data.user)
    } else {
      setUser(null)
    }
  }

Now we are checking that if everything goes ok then we're setting setUser(data.user.data.user) the user we get back from our backend-api else we are going to setUser to null and then we want to call this up here in a useEffect so let's go under our state and call the useEffect.

  useEffect(() => checkUserLoggedIn(), [])

Now we are going to have another api route for this because we need to destroy the cookie that's going to happened in our server which in our api route. So let's create a logout.js in our api folder. after we have done that, go ahead and paste the code inside of the logout.js file we just create. I will explain the code below.

import cookie from 'cookie'

export default = async (req, res) => {
  if (req.method === 'POST') {
    // DESTROY COOKIE
    res.setHeader(
      'Set-Cookie',
      cookie.serialize('token', '', {
        httpOnly: true,
        secure: process.env.NODE_ENV !== 'development',
        expires: new Date(0),
        sameSite: 'strict',
        path: '/'
      })
    )

    res.status(200).json({ message: "Success"})

  } else {
    res.setHeader('Allow', ['POST'])
    res.status(405).json({ message: `Method ${req.method} not allowed` })
  }
}

export default logout

All we are doing here is just to destroy the cookie. Now if you look at the cookie.serialize('token', '',) you will see that the token is now set to an empty string. Next we replace the maxAge with expires and we want to set it to something that's pass and we did that by passing a new data and pass in zero. And that's it this should destroy the cookie. Now from our logout function in out AuthContext we just want to call that api/logout.js Now add these code inside of the logout function inside of the AuthContext


  const logout = async () => {
    const res = await fetch(`${NEXT_URL}/api/logout`, {
      method: 'POST',
    })

    if (res.ok) {
      setUser(null)
      router.push('/login')
    }
  }

What we are doing here is simply hitting that api/logout route and we then setUser(null) to null, this will remove our cookie, and redirect the user to the login page. Now let's go to our Navbar components and bring in the logout method from AuthContext, So now update your navbar component with this code below

import { useContext } from 'react'

const { logout, user } = useContext(AuthContext)

{user ? <>
  <Link href="/dashboard">
    <a>Dashboard</a>
  </Link>
  <div>
    <a onClick={() => logout()}>Logout</a>
  </div>
</> : null}

Now once you click on logout everything should be working very fine, the cookie will be destroy. Now the next thing is the register page, basically this will do the same thing as login except it will create the user and then it will send back the token basically do the same thing the login response does.

Register User

Now let's go to our api folder and create our register.js file. Now go ahead and copy these code and paste inside of your register.js file.

import { API_URL } from '../../config/index'
import cookie from 'cookie'

const register = async (req, res) => {
  if (req.method === 'POST') {

    const {fullname, email, password} = req.body

    const apiRes = await fetch(`${API_URL}/your register endpoint`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        fullname,
        email,
        password
      }),
    })

    const resData = await apiRes.json()

    // console.log(resData.data.token)

    if (apiRes.ok) {
      // Set Cookie
      res.setHeader(
        'Set-Cookie',
        cookie.serialize('token', String(resData.data.token), {
          httpOnly: true,
          secure: process.env.NODE_ENV !== 'development',
          maxAge: 60 * 60 * 24 * 7, // 1 week
          sameSite: 'strict',
          path: '/'
        })
      )

      res.status(200).json({ user: resData.data })
    } else {
      res.status(500).json({message: resData.message})
    }
  } else {
    res.setHeader('Allow', ['POST'])
    res.status(405).json({ message: `Method ${req.method} not allowed` })
  }
}


export default register

Now if you take a close look you will see that we are doing the same thing as the login route, the little difference here is that we are accepting an extra field which is fullname. So next let's dive right into the AuthContext and handle the register route we have just created. You can copy the code below and paste it into the register async function we created.

 // Resister user
  // ====================================
  const signup = async ({ fullname, email, password }) => {
    const res = await fetch(`${NEXT_URL}/api/register`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ fullname, email, password }),
    })

    const resData = await res.json()

    if (res.ok) {
      setUser(resData.user)
      router.push('/dashboard')
    } else {
      setIsError(resData.message)
      setIsError(null)
    }
  }

Now we are hitting the api/register.js route that we just created, we are sending along the user object which is the fullname, email, password then we check to see if the response is ok, if is okay then we set the user and push/redirect to the dashboard and if there's an error we set that in the state. Now let's go inside the register and update our handleRegisterSubmit with these code


const handleRegisterSubmit = async ({ fullname, email, password }) => {
    register({ fullname, email, password })
  }

Now you can go ahead and test your app, everything should be working very fine now.

Conclusion

So having these API routes and being able to set an HTTP-only cookie is another big benefit of using Next.Js because is not something you can do with just React. Where to store JSON Web Tokens has always been kind of an issue with front-end development. So this does give us one solution.

Hello, I hope you liked the article. I am Caleb, a software developer. Let's connect at Twitter