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.
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;
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;
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;
}
);
},
});
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,
};
};
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;
};
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>;
};
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>
);
}
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>
);
};
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
},
});
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)
It is good, thank you so much for sharing this with us.
Nice article!
thank you !