May 20th, 2020

Access and Refresh Tokens with Next.js and Apollo

I recently started learning Next.js to build a full-stack React app with a GraphQL back-end. I watched a Level Up Tutorials course to get up and running with Apollo both on the server and client side.

Next up, I wanted to implement authentication using a JSON Web Token (JWT) and do it in a securely. Most blog posts and guides that I found online were storing the token either in local storage or a regular cookie and used a long expiration time. I was not enitrely happy with that approach and started researching the proper way of doing it.

I found this article by Hasura extremely helpful. It explains one possible implementation using a short-lived access token and a refresh token with a longer expiration period. I figured it would be relatively simple to code up using Next. Of course, I was wrong (or I'm just not as smart as I think). I spent the better part of the weekend trying to get all the pieces to talk to each other properly, and now I've finally managed to get there.

This article explains how I did it. If you'd like to skip to the end result, I published it in a GitHub repo, containing boilerplate code you can modify to your needs. I won't be explaining every single detail in this post, so I encourage you to go check it out if you want to learn more.

Disclaimer: I take IT security seriously, but I am certainly not a web security expert. I created this project because I wanted to better understand how to implement this type of flow. I would not use this in any real application and recommend that you do not either.

Authentication Flow

Whenever a user logs in, the server generates and signs a JWT access token containing the user's ID, and sends it back to the user. The user's browser can then use it to request access to other resources available via the GraphQL API. Since this token is sensitive, it should not be stored on the client's hard drive. It is kept in memory and has a short expiration time (using 15 minutes in this example).

This presents a couple of problems:

  • Whenever the token expires, the user has to sign in again.
  • Even if we implement some kind of automatic refresh, the token disappears from memory when they refresh the page or switch to a different browser tab.

This is obviously horrendous UX, so we add a refresh token into the mix. This could also be a JWT, but in our example, it is just a long, randomly generated string. The server sends it to the client as an HttpOnly cookie, making it inaccessible to client-side JavaScript. A hash of the refresh token along with its expiration time is stored in the database.

Moving forward, if a client does not have a valid access token, it can request a new one by sending its refresh token to the server. The server will verify that:

  1. The refresh token matches one of the hashes stored in the database for the particular user.
  2. The expiration time stored in the database has not passed.

If these two conditions are satisfied, it issues a new JWT access token as well as a new refresh token, deleting the old one from the database. Let's assume that refresh tokens are valid for 7 days. This gives the user a week-long "sliding window" - as long as they visit our app at least once per week, they remain logged in.

Creating the Back-End

Our back-end includes a MongoDB database for our user accounts and a single Apollo GraphQL API endpoint. The API will provide the ability to register a user account, sign in, retrieve some protected information and sign out.

If you'd like to follow along, you can either use create-next-app to get a Next app shell running and start manually installing the packages we are importing. Otherwise, feel free to grab all the code from the GitHub repo.

We are going to need some environment variables, which we place in a .env file in the root of the project folder. These will get parsed by the dotenv package.

BASE_URL=http://localhost:3000
JWT_EXPIRY=300
JWT_SECRET=SuperSecretKeyForSigningTokens
MIN_PASSWORD_LENGTH=10
MONGO_URI=<Connection string from MongoDB Atlas>
REFRESH_TOKEN_EXPIRY=604800

Then, in next.config.js, paste the following code to configure dotenv.

const { parsed: localEnv } = require('dotenv').config();
const webpack = require('webpack');

module.exports = {
  webpack(config) {
    config.plugins.push(new webpack.EnvironmentPlugin(localEnv));
    return config;
  },
  env: {
    BASE_URL: process.env.BASE_URL,
    JWT_EXPIRY: process.env.JWT_EXPIRY,
    JWT_SECRET: process.env.JWT_SECRET,
    MONGO_URI: process.env.MONGO_URI,
    REFRESH_TOKEN_EXPIRY: process.env.REFRESH_TOKEN_EXPIRY,
  },
};

Database

Our user account data needs to be stored somewhere. You could store it in memory at runtime, but for the sake of completeness, I decided to go with free cluster on MongoDB Atlas and wire up Mongoose to talk to it. That way, there's need to worry about our data disappearing between application restarts.

Create a new lib/mongoose.js file, import the mongoose package and define a connectDb function.

import mongoose from 'mongoose';

const connectDb = (handler) => async (req, res) => {
  if (mongoose.connections[0].readyState !== 1) {
    try {
      await mongoose.connect(**process.env.MONGO_URI**, {
        useNewUrlParser: true,
        useUnifiedTopology: true,
        useCreateIndex: true,
        useFindAndModify: false,
      });
    } catch (err) {
      console.error(err.message);
      process.exit(1);
    }
  }

  return handler(req, res);
};

The next couple of lines make sure that Next will log something to the server-side console, once there is an active database connection. Finally, export the connectDb function.

const db = mongoose.connection;
db.once('open', () => {
  console.log('MongoDB connected.');
});

export default connectDb;

Define a schema for our user accounts in a models/user.js file. The following data should be stored:

  • Name
  • Email address - this will also be the login name.
  • Password hash
  • Creation Date (not really used anywhere in our application but can come in handy)
  • List of refresh token hashes and expiration times.
import mongoose from 'mongoose';

const UserSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
  },
  email: {
    type: String,
    required: true,
    unique: true,
  },
  passwordHash: {
    type: String,
    required: true,
  },
  dateCreated: {
    type: Date,
    default: Date.now,
  },
  refreshTokens: [
    {
      hash: {
        type: String,
      },
      expiry: {
        type: Date,
      },
    },
  ],
});

export default mongoose.models.user || mongoose.model('user', UserSchema);

GraphQL Type Definitions

Now let's start defining our GraphQL API. Create a file for the type definitions at api/typeDefs.js.

import { gql } from 'apollo-server-micro';

const typeDefs = gql`
 # Type definitions here
`;

export default typeDefs;

The first type represents the user. The second one we call AuthPayload - this is what we want the server to send back to the user, once they have authenticated. It contains the JWT access token and a reference to the user's ID in the database. The token payload also includes the userId, but including it separately means we don't need to worry about decoding the token on the client side.

type User {
  id: ID!
  name: String!
  email: String!
  passwordHash: String!
}

type AuthPayload {
  token: String!
  userId: ID!
}

Now let's define some mutations, allowing users to register, sign in, refresh their tokens and sign out. The first three return an AuthPayload and signOutUser returns true if the sign-out was successful.

There is also a query for showing some private information to signed-in users.

type Mutation {
  registerUser(name: String!, email: String!, password: String!): AuthPayload!
  signInUser(email: String!, password: String!): AuthPayload!
  refreshUserToken(userId: ID!): AuthPayload!
  signOutUser(userId: ID!): Boolean!
}

type Query {
  showPrivateStuff: String!
}

GraphQL Resolvers

To resolve types, mutations and queries, we need some resolvers that will handle the authentication logic and talk to the database. Create these in api/resolvers.js with the following boilerplate.

import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import User from '../models/user';

/*
 * Options used in resolvers to issue the refresh token cookie.
 */
const REFRESH_TOKEN_COOKIE_OPTIONS = {
  // Get part after // and before : (in case port number in URL)
  // E.g. <http://localhost:3000> becomes localhost
  domain: process.env.BASE_URL.split('//')[1].split(':')[0],
  httpOnly: true,
  path: '/',
  sameSite: true,
  // Allow non-secure cookies only in development environment without HTTPS
  secure: !!process.env.BASE_URL.includes('https'),
};

const resolvers = {
 Mutation: {
    registerUser: async (parent, { email, name, password }, { setCookies }) => {
      // TODO
    },

    signInUser: async (parent, { email, password }, { setCookies }) => {
      // TODO
    },

    refreshUserToken: async (parent, { userId }, { req, setCookies }) => {
      // TODO
    },

    signOutUser: async (parent, { userId }, { req, setCookies }) => {
      // TODO
    },
  },

  Query: {
    showPrivateStuff: async (parent, args, { user }) => {
      if (!user) throw new Error('Not authenticated');
      return '🤫 Secret private stuff';
    },
  },
};

export default resolvers;

As you can see, we are importing some additional packages:

  • jwt for creating and signing access tokens.
  • uuidv4 for generating refresh tokens.
  • bcrypt to generate and verify hashes of passwords and refresh tokens.

Each resolver function receives from the GraphQL server as its second and third parameter the following objects:

  • args - contains all arguments that were provided by the GraphQL client in the query or mutation.
  • context - this is a shared context provided by the GraphQL server to each resolver. It gives us access to the HTTP request object and a setCookies array.

Both arguments are destructured to get direct access to the required properties.

I won't go over every resolver in detail but you are, of course, welcome to check out the repo. However, I would like to step through the refreshUserToken as this was the one took me a bit of time to figure out.

From the arguments, we have access to userId, req and setCookies.

First, we check if an existing refresh token exists in the request cookies and if the user ID is actually found in our database. If not, we throw an error.

const { refreshToken } = req.cookies;
if (!refreshToken) throw new Error('No refresh token provided');

const foundUser = await User.findById(userId);
if (!foundUser) throw new Error('Invalid user');

From here on, the server knows that the user ID is valid but still needs to verify the refresh token. It checks all refresh tokens stored in the database against this user ID. For each of them, it does the following:

  • Checks if the hash matches the token we were given.
  • Checks if the expiry date has passed or not, i.e. if the stored hash is still valid.

If both of the above are true, the refresh token is valid. It gets deleted from the database because a new one will be issued shortly (remember the sliding window). Any expired tokens that are found during the loop, get deleted (hence the use of the filter array method). Returning !isMatch && isValid ensures that only tokens that were not a match and are still valid are kept in the array.

let isRefreshTokenValid = false;
foundUser.refreshTokens = foundUser.refreshTokens.filter(
  (storedToken) => {
    const isMatch = bcrypt.compareSync(refreshToken, storedToken.hash);
    const isValid = storedToken.expiry > Date.now();
    if (isMatch && isValid) {
      isRefreshTokenValid = true;
    }
    return !isMatch && isValid;
  }
);

If the the token was not valid, an error is thrown. Otherwise, a brand new refresh token gets generated and sent back to the client in a cookie. The new token's hash and expiry are added to the database.

if (!isRefreshTokenValid) throw new Error('Invalid refresh token');

const newRefreshToken = uuidv4();
const newRefreshTokenExpiry = new Date(
  Date.now() + parseInt(process.env.REFRESH_TOKEN_EXPIRY) * 1000
);

setCookies.push({
  name: 'refreshToken',
  value: newRefreshToken,
  options: {
    ...REFRESH_TOKEN_COOKIE_OPTIONS,
    expires: newRefreshTokenExpiry,
  },
});

const salt = await bcrypt.genSalt(10);
const newRefreshTokenHash = await bcrypt.hash(newRefreshToken, salt);

foundUser.refreshTokens.push({
  hash: newRefreshTokenHash,
  expiry: newRefreshTokenExpiry,
});

await foundUser.save();

Finally, a new access token must be issued. The payload object contains the user's ID and is signed using jwt.sign(). The userId and token are sent back in the response body - this is the AuthPayload GraphQL type.

const payload = {
  user: {
    id: foundUser._id,
  },
};
const token = await jwt.sign(payload, process.env.JWT_SECRET, {
  expiresIn: parseInt(process.env.JWT_EXPIRY),
});

return { userId: foundUser.id, token };

Apollo Server

To tie it all together, create a pages/api/graphql.js file. This creates an instance of ApolloServer and a request handler.

The context property of ApolloServer refers to a function that has access to the client's HTTP request and performs the following:

  1. Extracts the access token from the request headers, if one exists.
  2. Tries to verify the access token, i.e. make sure it is still valid and was signed using the server's secret.
  3. If validation passes, the user object gets destructured from the token payload and passed to the resolver along with setCookies, setHeaders and the request object. If the token is not valid, the user object is left out.
import { ApolloServer } from 'apollo-server-micro';
import httpHeadersPlugin from 'apollo-server-plugin-http-headers';
import jwt from 'jsonwebtoken';
import resolvers from '../../api/resolvers';
import typeDefs from '../../api/typeDefs';
import connectDb from '../../lib/mongoose';

const apolloServer = new ApolloServer({
  typeDefs,
  resolvers,
  // httpHeadersPlugin allows us to send cookies back to the client
  plugins: [httpHeadersPlugin],
  context: async ({ req }) => {
    // Header is in form 'Bearer <token>', grabbing the part after ' '
    const token = req.headers.authorization?.split(' ')[1] || undefined;
  // Initialise as empty arrays - resolvers will add items if required
    const setCookies = [];
    const setHeaders = [];
    try {
      const { user } = jwt.verify(token, process.env.JWT_SECRET);
      return { req, setCookies, setHeaders, user };
    } catch (error) {
      return { setCookies, setHeaders, req };
    }
  },
});

Exporting the config object disables Next's request body parsing as Apollo will be taking care of that. Finally, create a handler to manage the request and response lifecycle, pass it into the connectDb and export the result. This ensures that there is an active database connection before the API route becomes live.

export const config = {
  api: {
    bodyParser: false,
  },
};
const handler = apolloServer.createHandler({ path: '/api/graphql' });
export default connectDb(handler);

Our back-end is now ready. If we run yarn dev in our terminal, we can browse to http://localhost:3000/api/graphql and get access to GraphQL Playground for testing out our API.

Hooking Up the Front-End

Keeping Track of Authentication State

We need a way to keep track of authentication state throughout our app. We want to know whether to show public or private information, if we should display a Sign In or Sign Out button throughout our application, etc.

Create a components/AuthProvider.js file, which is the wrapper for the rest of the front-end and uses a set of React Hooks. In the application state, we need to keep our access token as well as user ID, both of which will initially be undefined.

import { createContext, useContext, useEffect, useState } from 'react';

const AuthContext = createContext();

const AuthProvider = ({ children }) => {
  const initialAuthState = {
    token: undefined,
    userId: undefined,
  };
  const [authState, setAuthState] = useState(initialAuthState);
  
  // ...
};

// ...

Inside the AuthProvider component, define three functions for manipulating authState:

  • signIn for setting the userId and token as well as storing userId in local storage.
  • setAuthToken for setting a new token when it is refreshed.
  • signOut for clearing the state as well as local storage.
  const signIn = (userId, token) => {
    setAuthState({
      ...authState,
      userId,
      token,
    });
    if (typeof window !== 'undefined') {
      localStorage.setItem('userId', userId);
    }
  };

  const setAuthToken = (token) => {
    setAuthState({
      ...authState,
      token,
    });
  };

  const signOut = () => {
    setAuthState(initialAuthState);
    if (typeof window !== 'undefined') {
      localStorage.removeItem('userId');
    }
  };

Notice how React knows nothing about the refresh token cookie. This will get sent to the back-end by the browser. However, you will need to store a reference to the user ID in local storage - this is to tell the back-end which user you are claiming to be when requesting a refresh.

Add a useEffect hook to keep local storage userId and authState userId in sync.

 useEffect(() => {
    if (localStorage.getItem('userId') !== authState.userId) {
      setAuthState({
        ...authState,
        userId: localStorage.getItem('userId'),
      });
    }
  }, [authState]);

  return (
    <AuthContext.Provider value={{ authState, setAuthToken, signIn, signOut }}>
      {children}
    </AuthContext.Provider>
  );

Finally, create a custom useAuth hook to access our authState and related functions from other components. Export the AuthProvider and useAuth.

const useAuth = () => useContext(AuthContext);

export { AuthProvider as default, useAuth };

In pages/_app.js you can define a special MyApp component, which wraps all page components with the AuthProvider. This provides access to authState. I'm also importing custom styles here (I'm using Tailwind).

import '../styles/tailwind.css';
import AuthProvider from '../components/AuthProvider';

const MyApp = ({ Component, pageProps }) => (
  <AuthProvider>
    <Component {...pageProps} />
  </AuthProvider>
);

export default MyApp;

Apollo Client

This was by far the most complex piece of the puzzle for me. Getting Apollo Client to use the correct token refresh flow took me several attempts to get going without any errors. The apollo-link-token-refresh package ended up being the key to getting this working.

I will admit that I don't fully understand all the details here. I managed to put it together based on this example and a fair amount of additional reading. I decided not to worry about any Server-Side Rendering support for Apollo Client to keep things somewhat simpler.

Create a lib/apollo.js file. At a high level, it contains:

  • An initApolloClient function that takes our access token, userId and setAuthToken function as a parameters.
  • A withApollo Higher Order Component (HOC) which will be used to wrap the components that need to use Apollo.
import { ApolloProvider } from '@apollo/react-hooks';
import { InMemoryCache } from 'apollo-cache-inmemory';
import ApolloClient from 'apollo-client';
import { setContext } from 'apollo-link-context';
import { createHttpLink } from 'apollo-link-http';
import { TokenRefreshLink } from 'apollo-link-token-refresh';
import fetch from 'isomorphic-unfetch';
import jwt from 'jsonwebtoken';
import { useAuth } from '../components/AuthProvider';

const initApolloClient = (initialState = {}, token, userId, setAuthToken) => {
  // ...
};

const withApollo = (PageComponent) => {
  const WithApollo = ({ apolloClient, apolloState, ...pageProps }) => {
    const { authState, setAuthToken } = useAuth();

    const client =
      apolloClient ||
      initApolloClient(
        apolloState,
        authState.token,
        authState.userId,
        setAuthToken
      );

    return (
      <ApolloProvider client={client}>
        <PageComponent {...pageProps} />
      </ApolloProvider>
    );
  };

  return WithApollo;
};

export default withApollo;

The body of the initApolloClient function is where most of the magic happens. It defines the API endpoint (httpLink), how to send the access token (authLink) and when to request a new token (refreshLink).

  const cache = new InMemoryCache().restore(initialState);
  
  const httpLink = createHttpLink({
    uri: `${process.env.BASE_URL}/api/graphql`,
    fetch,
    credentials: 'include',
  });

  const authLink = setContext((_, { headers }) =>
    // return the headers to the context so httpLink can read them
    ({
      headers: {
        ...headers,
        authorization: token ? `Bearer ${token}` : '',
        userid: userId,
      },
    })
  );

  const refreshLink = new TokenRefreshLink({
    accessTokenField: 'newToken',
    // No need to refresh if token exists and is still valid
    isTokenValidOrUndefined: () => {
      // No need to refresh if we don't have a userId
      if (!userId) {
        return true;
      }
      // No need to refresh if token exists and is valid
      if (token && jwt.decode(token)?.exp * 1000 > Date.now()) {
        return true;
      }
    },
    fetchAccessToken: async () => {
      if (!userId) {
        // no need to refresh if userId is not defined
        return null;
      }
      // Use fetch to access the refreshUserToken mutation
      const response = await fetch(`${process.env.BASE_URL}/api/graphql`, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
        },
        body: JSON.stringify({
          query: `mutation {
                    refreshUserToken(userId: "${userId}") {
                      userId
                      token
                    }
                  }`,
        }),
      });
      return response.json();
    },
    handleFetch: (newToken) => {
      // save new authentication token to state
      setAuthToken(newToken);
    },
    handleResponse: (operation, accessTokenField) => (response) => {
      if (!response) return { newToken: null };
      return { newToken: response.data?.refreshUserToken?.token };
    },
    handleError: (error) => {
      console.error('Cannot refresh access token:', error);
    },
  });

  const client = new ApolloClient({
    ssrMode: false,
    link: authLink.concat(refreshLink).concat(httpLink),
    cache,
  });
  return client;

Pages and Components

With Apollo Client configured, you can create the remaining pages and components. Any page or component that needs to use a GraphQL query must be "wrapped" with the withApollo Higher Order Component (HOC) on export.

I created a pages/index.js file as the only page component in the application. It imports additional components and renders them based on whether the user is signed in or not. I'm not going to go through each of them individually as they are just regular React components.

import { useAuth } from '../components/AuthProvider';
import Layout from '../components/Layout';
import PrivateComponent from '../components/PrivateComponent';
import PublicComponent from '../components/PublicComponent';
import withApollo from '../lib/apollo';

const Home = () => {
  const { authState } = useAuth();

  return (
    <Layout title="Home">
      {authState.userId ? <PrivateComponent /> : <PublicComponent />}
    </Layout>
  );
};

export default withApollo(Home);

If you want to access the authState or its related functions, use the custom useAuth hook in any of your components. For example, here is the code behind our event handler for the Sign In button. The handleSignIn function gets called when the form is submitted. It then calls the signInUser GraphQL mutation using Apollo Client. If the server sends back a valid userId and token, it saves them in authState, and we are logged in!

// ...

const SIGN_IN_MUTATION = gql`
  mutation SignInMutation($email: String!, $password: String!) {
    signInUser(email: $email, password: $password) {
      token
      userId
    }
  }
`;

// ...

const { signIn } = useAuth();
const [signInMutation] = useMutation(SIGN_IN_MUTATION);
const client = useApolloClient();

const handleSignIn = async (event) => {
  event.preventDefault();
  const { email, password } = event.target.elements;

  try {
    await client.resetStore();
    const { data } = await signInMutation({
      variables: {
        email: email.value,
        password: password.value,
      },
    });
    if (data?.signInUser) {
      const { userId, token } = data.signInUser;
      signIn(userId, token);
    }
  } catch (error) {
    console.error('Something went wrong during sign in:', error);
  }
};

// ...

Recap

There it is - a boiler plate for a full-stack application using Next.js and Apollo. There was a lot to get through, and if you are still reading, I applaud you! A working and complete version of this can be found in this GitHub repo - go ahead and check it out!

© Siim Männart 2020