DEV Community

Cover image for Building a Secure Next.js Application with Redux Toolkit Query and cookie tokens
Fernando González Tostado
Fernando González Tostado

Posted on • Updated on

Building a Secure Next.js Application with Redux Toolkit Query and cookie tokens

Authentication and refresh tokens are essential for securing web applications. However, properly handling sessions and expirations can become a challenge. It's not always clear how to implement these features in a way that is both secure and user-friendly.

In this blog post I'll show you how to leverage from the phantastic Redux Toolkit Query library and web storage (cookies) to implement a robust authentication system that allows users to stay logged in for as long (almost) as they want without having to worry about expiring tokens or session timeouts.

Why Redux Toolkit and not React Query?

RQ is a great library for data fetching and caching, but RTKQ is more powerful and flexible. It gives us the full power of handling server state, similar to RQ, while also keeping the powerful features from Redux Toolkit.

What are authentication and refresh tokens?

Authentication tokens are used to verify that a user is who they say they are. They are typically generated by the server when a user logs in and are stored in the browser's local storage or session storage.

Usually authentication tokens are short-lived and expire after a certain amount of time. This is done for security reasons, so that if someone steals your token they can't use it forever. However, this can be inconvenient for users who want to stay logged in for longer periods of time. To solve this problem, refresh tokens are used. However, in this blog post we will focus on authentication tokens.

I like storing tokens in the cookies because they have expiration dates, security flags such as httpOnly, SameSite and Secure which make them less accessible to malicious actors whereas local storage is accessible to any script running on the page. Here is a great article explaining why cookies are better than local storage for storing tokens.

Using a wrapper layour strategy to persist authentication state

The wrapper layout strategy is a common approach to managing authentication state in Next.js applications. It involves creating a custom layout component that wraps all of your application's pages. This component checks the authentication status of the user and redirects them to the login page if they are not authenticated.

app high level diagram

This approach has several benefits:

  • Simplicity: This approach to authentication management is relatively simple to implement, even for developers who are new to Redux Toolkit and Next.js.
  • Security: The wrapper layout strategy ensures that all pages of your application are protected by authentication. This helps to prevent unauthorized users from accessing your application's resources.
  • Usability: The refresh token mechanism ensures that users are not logged out unexpectedly if their access token expires. This improves the user experience by making it easier for users to stay logged in to your application.

Implementation

Enough theory, let's get our hands dirty. I expect you to have a basic understanding of Next.js, Redux Toolkit and Redux Toolkit Query and Next.js. You don't really need to have an API with authentication endpoints since we will mock the request and will focus on handling the tokens and the authentication state.

Setting up RTKQ

Considering that we have an existing Next.js with a Redux Toolkit store we will have the auth slice where we're going to set the authentication state and the user slice where we're going to store the user data.

// store/auth.ts

// this is how the API response could look like
type LoginResponse = {
  token: string;
  userEmail: string;
  userName: string;
  id: string;
};

const initialState: Partial<LoginResponse> = {};

const slice = createSlice({
  name: 'auth',
  initialState,
  reducers: {},
});

export const authReducer = slice.reducer;
Enter fullscreen mode Exit fullscreen mode

Now lets setup the api slice. We will use the createApi function from RTKQ to create the api slice that will initially have a mutation for the login endpoint and a query for the user endpoint which will be used to get the user data when the user comes back to the application and his token is still valid.

// store/authApi.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const authApi = createApi({
  reducerPath: 'authApi',
  baseQuery: fetchBaseQuery({
    baseUrl:
      typeof window === 'undefined'
        ? 'http://localhost:3000'
        : window.location.origin,
  }),
  endpoints: (builder) => ({
    login: builder.mutation<LoginResponse, { userName: string, password: string }>({
      query: ({ userName, password }) => ({
        url: '/api/login',
        method: 'POST',
        body: {
          userName,
          password,
        },
      }),
    }),
    getAuthData: builder.query<LoginResponse, { token: string }>({
      query: ({ token }) => ({
        url: 'api/auth-details',
        // this is the default but I'm leaving it here for reference
        method: 'GET',
        headers: {
          Authorization: `Bearer ${token}`,
        },
      }),
    }),
  }),
});

export const { useLoginMutation, useGetAuthDataQuery } = authApi;
Enter fullscreen mode Exit fullscreen mode

Now we need to add the api matchers to the auth slice so that we can update the state when the login or getAuthData endpoints are called. The matchFulfilled matcher will be used to update the state when the request is successful. There are other matchers (info ) that can be used to update the state when the request is rejected or pending.

We'll also declare a setAuthCookie method that will base64 encode and store the token in the browser cookies. I like the cookies-next library for this but you can use any other library or even the native document.cookie API.

import { setCookie } from 'cookies-next';
import { createSlice } from '@reduxjs/toolkit';

// store/auth.ts
const setAuthCookie = (token: string, name: string) => {
  const toBase64 = Buffer.from(token).toString('base64');

  setCookie(name, toBase64, {
    maxAge: 30 * 24 * 60 * 60,
    path: '/',
    // more security options here
    // sameSite: 'strict',
    // httpOnly: true,
    // secure: process.env.NODE_ENV === 'production',
  });
};

const slice = createSlice({
  name: 'auth',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addMatcher(
        authApi.endpoints.login.matchFulfilled,
        (_state, { payload }) => {
          // set the token in the cookies
          setAuthCookie(payload.token, 'auth_token');

          // store the user data in the store
          // "mutation" also works
          // state = payload;
          return payload;
        }
      )
      .addMatcher(
        authApi.endpoints.getAuthData.matchFulfilled,
        (_state, { payload }) => {
          // in case we receive a new token when refetching the details
          setAuthCookie(payload.token, 'auth_token');
          return payload;
        }
      );
  },
});
Enter fullscreen mode Exit fullscreen mode

Lets declare some helpers to get the cookies and return possible valid tokens.

// lib/cookies.ts
import { getCookie } from 'cookies-next';

// helpers to get cookies
const getAuthCookie = (name: string) => {
  const cookie = getCookie(name);

  if (!cookie) return undefined;

  return Buffer.from(cookie, 'base64').toString('ascii');
};

export const getValidAuthTokens = () => {
  const token = getAuthCookie('auth_token');

  const now = new Date();
  const tokenDate = new Date(token || 0);

  return {
    token: now < tokenDate ? token : undefined,
  };
};
Enter fullscreen mode Exit fullscreen mode

Setting up the wrapper

Finally, it's time to create the wrapper layout component. This component will be used to wrap all of our application's authenticated pages. It will check the authentication status of the user, if it finds a valid token and no auth details in the store it will call the getAuthData endpoint to get the user data and update the store. If it doesn't find a valid token it will delete any possibly existing token and redirect the user to the login page.

// components/AuthWrapper.tsx
import { useRouter } from 'next/router';
import { useDispatch, useSelector } from 'react-redux';
import { getValidAuthTokens } from '@/lib/cookies';

type Props = {
  children?: React.ReactNode;
};

export const AuthWrapper = ({ children }: Props) => {
  const dispatch = useDispatch();
  const { push } = useRouter();
  const { userEmail } = useSelector((state: RootState) => state.auth);

  const { token } = getValidAuthTokens();

  // this query will only execute if the token is valid and the user email is not already in the redux store
  const { error, isLoading } = useGetAuthDataQuery(
    { token: token || '' },
    {
      // The useGetAuthDataQuery hook will not execute the query at all if these values are falsy
      skip: !!userEmail || !token,
    }
  );

  // if the user doesnt have a valid token, redirect to login page
  useEffect(() => {
    if (!token) {
      push('/login');
      // will explain this in a moment
      dispatch(logout());
    }
  }, [token, push]);

  // optional: show a loading indicator while the query is loading
  if (isLoading) {
    return <div>Loading...</div>;
  }

  return children;
};
Enter fullscreen mode Exit fullscreen mode

The key in this component is using the skip option in the useGetAuthDataQuery hook. This option allows us to skip the execution of the query if the token is not valid or if the user info (we used userEmail but you could use any other auth property) is already in the store. This is important because we don't want to execute the query every time the user navigates to a wrapped route. We only want to execute this query when the user comes back -eg. reloading- to the application and his token is still valid.

We can also make use of the cache feature of RTQK keepUnusedDataFor to keep the user data in the cache for a certain amount of time.

We can wrap all the routes that should be authenticated using the Page.getLayout feature from Next.js. This will allow us to wrap all the pages that should be authenticated with the AuthWrapper component.

// pages/some-protected-page.tsx
export default function SomeProtectedPage() {
  return (
    <div>
      <h1>Some protected page content</h1>
    </div>
  );
}

Page.getLayout = function getLayout(page) {
  return <AuthWrapper>{page}</AuthWrapper>;
};
Enter fullscreen mode Exit fullscreen mode

This should be enable in _app.tsx so that it applies to all the pages. More info about per page getLayout here.

// pages/_app.tsx
import { store } from '@/store';
import { Provider } from 'react-redux';

export default function MyApp({ Component, pageProps }) {
  // Use the layout defined at the page level, if available
  const getLayout = Component.getLayout || ((page) => page);

  return (
    <Provider store={store}>{getLayout(<Component {...pageProps} />)}</Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

You can also individually wrap every protected page with the AuthWrapper component. This approach is also valid, however I don't find it as clean as the previous one.

// pages/protected-page.tsx
const ProtectedPage = () => {
  return (
    <AuthWrapper>
      {
        // page content
      }
    </AuthWrapper>
  );
};
Enter fullscreen mode Exit fullscreen mode

Finally, adding the logout action to the auth slice will allow us to clear the cookies and the store when the user logs out. We will not create an api endpoint —therefore we will declare the action in the reducers keys— for this since we don't need to make a request to the server to log out. But depending on your use case you might want to create an api endpoint for this with its corresponding api matcher.

// store/auth.ts
import { removeCookie } from 'cookies-next';

const slice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    logout: () => {
      removeCookie('auth_token');
      // clear the auth slice data
      return {};
    },
  },
  extraReducers: (builder) => {
    // here we have the api matchers — login and getAuthData
  },
});
Enter fullscreen mode Exit fullscreen mode

If you wish to see a PoC implementation you can find it here

Conclusion

Redux Toolkit Query is a powerful library that can be used to simplify authentication management in Next.js applications. By using the wrapper layout strategy, you can easily implement a robust authentication system that is both secure and user-friendly. If you have some previous experience using Redux (React Query experience helps too!) in any form, you should be able to implement this approach without much trouble.


Photo from Edan Cohen at Unsplash

Top comments (3)

Collapse
 
avathiel profile image
Ava Thiel

It is good, thank you so much for sharing this with us.

Collapse
 
goozhi profile image
wrvr

Nice article!

Collapse
 
esponges profile image
Fernando González Tostado

thank you !