Max SchmittMS
24th September 2020

Next.js: Using HTTP-Only Cookies for Secure Authentication

Most modern REST and GraphQL APIs expect an authentication token as an HTTP header in order to identify the currently logged-in user.

That means the frontend application needs to store the user's auth token somewhere.

The Problem with localStorage and Normal Cookies

Many SPAs and Next.js apps simply keep their auth tokens in localStorage or just a normal cookie.

Flowchart showing a client making an API request that is authenticated by an auth-token header

Unfortunately localStorage and simple cookies aren't the safest place to keep sensitive data because both can be accessed from third-party scripts and browser extensions.

In this post I will show you how to keep your JWT or other kind of auth token in an HTTP-only cookie with Next.js.

Why use HTTP-Only Cookies for Authentication?

HTTP-only cookies can't be accessed from client-side JavaScript, so third-party scripts and browser extensions won't even know they exist.

You can identify them by looking for the httponly attribute in the set-cookie header of an HTTP response:

set-cookie: auth-token=123; path=/; samesite=lax; httponly

Our own first-party JavaScript can't access HTTP-only cookies either though... so how do we get them into the HTTP headers of our API calls?

Instead of calling the API directly, our AJAX calls will go through an API Proxy – basically a thin layer in front of the actual API.

The API Proxy will exist on the same host/domain as our Next.js app and act as a sort of "translation layer". By existing on the same domain as our Next.js app, it can access the same cookies. So it can read the HTTP-only auth token cookie and "translate" it into an auth-token HTTP header that the API understands.

As a pleasant side effect, this also eliminates CORS issues, since the only AJAX requests we'll be making, will be to the same origin.

So how does this API Proxy work? It basically has two jobs:

Whenever a user logs in, the API Proxy needs to intercept the API call for login and save an auth-token cookie from the API response.

Flowchart showing how an API proxy intercepts a successful API response after login to set an HTTP-only cookie

Most APIs don't accept cookies for authentication themselves so we'll have our API Proxy "translate" our authentication cookies into HTTP headers before forwarding our requests to the actual API.

Flowchart showing how an API proxy intercepts client requests that contain an auth-token in an HTTP-only cookie and forwards them to an API with an auth-token header instead

Now that we've looked at how an API Proxy for HTTP-only auth token cookies works, let's implement it in Next.js.

Adding an API Proxy to Next.js

Thanks to Next.js' API catch-all routes, we can easily add the API proxy directly to Next.js itself without having to setup e.g. an Express server.

Pay attention to the name of the file we'll be creating: pages/api/[...path].js.

We'll be using two helpful packages from npm: http-proxy (lets us create a proxy server) and cookies (makes it easier to deal with cookies in Next.js). You can install them by running:

$ yarn add http-proxy cookies

Thanks to these packages, the actual API Proxy implementation isn't even 100 lines long.

pages/api/[...path].js

import httpProxy from 'http-proxy'
import Cookies from 'cookies'
import url from 'url'
// Get the actual API_URL as an environment variable. For real
// applications, you might want to get it from 'next/config' instead.
const API_URL = process.env.API_URL
const proxy = httpProxy.createProxyServer()
// You can export a config variable from any API route in Next.js.
// We'll use this to disable the bodyParser, otherwise Next.js
// would read and parse the entire request body before we
// can forward the request to the API. By skipping the bodyParser,
// we can just stream all requests through to the actual API.
export const config = {
api: {
bodyParser: false
}
}
export default (req, res) => {
// Return a Promise to let Next.js know when we're done
// processing the request:
return new Promise((resolve, reject) => {
// In case the current API request is for logging in,
// we'll need to intercept the API response.
// More on that in a bit.
const pathname = url.parse(req.url).pathname
const isLogin = pathname === '/api/login'
// Get the `auth-token` cookie:
const cookies = new Cookies(req, res)
const authToken = cookies.get('auth-token')
// Rewrite the URL: strip out the leading '/api'.
// For example, '/api/login' would become '/login'.
// ️You might want to adjust this depending
// on the base path of your API.
req.url = req.url.replace(/^\/api/, '')
// Don't forward cookies to the API:
req.headers.cookie = ''
// Set auth-token header from cookie:
if (authToken) {
req.headers['auth-token'] = authToken
}
// In case the request is for login, we need to
// intercept the API's response. It contains the
// auth token that we want to strip out and set
// as an HTTP-only cookie.
if (isLogin) {
proxy.once('proxyRes', interceptLoginResponse)
}
// Don't forget to handle errors:
proxy.once('error', reject)
// Forward the request to the API
proxy.web(req, res, {
target: API_URL,
// Don't autoRewrite because we manually rewrite
// the URL in the route handler.
autoRewrite: false,
// In case we're dealing with a login request,
// we need to tell http-proxy that we'll handle
// the client-response ourselves (since we don't
// want to pass along the auth token).
selfHandleResponse: isLogin
})
function interceptLoginResponse(proxyRes, req, res) {
// Read the API's response body from
// the stream:
let apiResponseBody = ''
proxyRes.on('data', (chunk) => {
apiResponseBody += chunk
})
// Once we've read the entire API
// response body, we're ready to
// handle it:
proxyRes.on('end', () => {
try {
// Extract the authToken from API's response:
const { authToken } = JSON.parse(apiResponseBody)
// Set the authToken as an HTTP-only cookie.
// We'll also set the SameSite attribute to
// 'lax' for some additional CSRF protection.
const cookies = new Cookies(req, res)
cookies.set('auth-token', authToken, {
httpOnly: true,
sameSite: 'lax'
})
// Our response to the client won't contain
// the actual authToken. This way the auth token
// never gets exposed to the client.
res.status(200).json({ loggedIn: true })
resolve()
} catch (err) {
reject(err)
}
})
}
})
}

With the API Proxy in place, we have everything we need to start using HTTP-only cookies for our auth tokens (JWT or otherwise) in Next.js.

Next Steps

In order to complete the story, we'll want to think about a few more things:

  • We need to make sure all client-side API requests go to the proxy instead of the API directly
  • We need a mechanism for logging out the user – either a "logout" page or an API endpoint we can call to unset the auth-token cookie. This needs to happen on the server somewhere because we also can't unset HTTP-only cookies from the client
  • On the server, e.g. during getInitialProps() or getServerSideProps(), we need to make requests to the API directly

Example App on GitHub

I've put up a self-contained example application on GitHub for you to check out.

It contains the API Proxy from above, a demo login, as well as solutions for logout and server-side API requests.

Thanks to my friends Amal and Saša for proof-reading this article.


Need help on a project?

I'm available for freelance work.
Send me an email! Or find out more about my services.