DEV Community

loading...

Detecting Authentication Client-Side in Next.js with an HttpOnly Cookie When Using SSR

justincy profile image Justin Updated on ・6 min read

The architecture explained here only supports SSR. If you need to support Static Optimization then read my follow up post.

The best security practice is to store a session identifier or token in an HttpOnly cookie. HttpOnly cookies are not available to JavaScript, they are only sent to the server. This prevents 3rd party scripts from hijacking the session. However, this also prevents your own JavaScript from accessing the token. That's generally okay for apps that server-render all pages, but how do we deal with this in Next.js with pages that are rendered in the client?

First, let's acknowledge that the client doesn't validate sessions, only the server does. Clients typically just look to see if a session cookie is set and assume it's valid. In other words, clients treat the cookie as a boolean to answer the question: is the user logged in?

Why does the client need to know whether the user is logged in? It's an optimization. Let's imagine how a client-rendered app would behave where the client didn't know whether the user was authenticated. When first visiting the site, you'd see the home page with a login button in the header. If the user logs in, the login code could know to send the user to a logged-in home page. What if the user hit the back button? The app would render the login page again. This isn't ideal. Why allow the user to login again? We're wasting the user's time.

A few days later, this same user clicks a bookmark to load their logged-in home page. The page renders the full shell and a loading spinner to fetch data we need to populate the latest activity. Oops! The server responds with a 401. The user isn't authenticated. Now the user is sent to the login page. We just wasted a few seconds of their time, a little of their bandwidth, and some of our server resources (one hit doesn't make much of a difference but this adds up quickly over time).

These simple examples reveal that allowing the client to know whether the user is authenticated is just an optimization that aids primarily in routing and rendering. We want to avoid rendering pages and fetching data that the user can't see. We also want to prevent authenticated users from participating in registration, login, and reset-password flows.

One way to achieve this in Next.js is with page-level HOCs. It would be nice to have two HOCs: withAuth() that only renders the page if the user is authenticated and withoutAuth() that only renders the page if the user is not authenticated. It'd be nice to specify where the user should be sent if the required auth condition isn't met, so the first parameter will be the page component and the second parameter will be a URL.

The auth HOCs need access to the authenticated state. This could be done through a hook: useIsAuthenticated(). That hook will need to pull the value from some global state store. That will be accomplished through the Context API.

import React from 'react';

const AuthContext = React.createContext({
  isAuthenticated: false,
  setAuthenticated: () => {}
});

/**
 * The initial value of `isAuthenticated` comes from the `authenticated`
 * prop which gets set by _app. We store that value in state and ignore
 * the prop from then on. The value can be changed by calling the
 * `setAuthenticated()` method in the context.
 */
export const AuthProvider = ({
  children,
  authenticated
}) => {
  const [isAuthenticated, setAuthenticated] = React.useState(authenticated);
  return (
    <AuthContext.Provider
      value={{
        isAuthenticated,
        setAuthenticated
      }}
    >
      {children}
    </AuthContext.Provider>
  );
};

export function useAuth() {
  const context = React.useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

export function useIsAuthenticated() {
  const context = useAuth();
  return context.isAuthenticated;
}

The AuthProvider accepts an authenticated prop which represents the initial authenticated value. The initial value will be calculated and passed in to the AuthProvider in a custom App.

Note that we also included two hooks which make it easy for the rest of our application to be informed about the current authenticated state: useAuth() and useIsAuthenticated(). But before they can be used, we must add AuthProvider to our application.

import React from 'react';
import App from 'next/app';
import { AuthProvider } from '../providers/Auth';
// Be sure to install this package for parsing cookies
import cookie from 'cookie';

class MyApp extends App {
  render() {
    const { Component, pageProps, authenticated } = this.props;
    return (
      <AuthProvider authenticated={authenticated}>
        <Component {...pageProps} />
      </AuthProvider>
    );
  }
}

MyApp.getInitialProps = async (appContext) => {
  let authenticated = false;
  const request = appContext.ctx.req;
  if (request) {
    request.cookies = cookie.parse(request.headers.cookie || '');
    authenticated = !!request.cookies.session;
  }

  // Call the page's `getInitialProps` and fill `appProps.pageProps`
  const appProps = await App.getInitialProps(appContext);

  return { ...appProps, authenticated };
};

export default MyApp;

Now that the auth state is being initialized, stored in context, and retrieved through the auth hooks, we are ready to create the withAuth() and withoutAuth() HOCs. Their API and general logic is almost all the same. The only difference is one checks for true and one checks for false. So first we'll build an HOC that they'll share. Let's call it withConditionalRedirect().

import { useRouter } from 'next/router';

function isBrowser() {
  return typeof window !== 'undefined';
}

/**
 * Support conditional redirecting, both server-side and client-side.
 *
 * Client-side, we can use next/router. But that doesn't exist on the server.
 * So on the server we must do an HTTP redirect. This component handles
 * the logic to detect whether on the server and client and redirect
 * appropriately.
 *
 * @param WrappedComponent The component that this functionality
 * will be added to.
 * @param clientCondition A function that returns a boolean representing
 * whether to perform the redirect. It will always be called, even on
 * the server. This is necessary so that it can have hooks in it (since
 * can't be inside conditionals and must always be called).
 * @param serverCondition A function that returns a boolean representing
 * whether to perform the redirect. It is only called on the server. It
 * accepts a Next page context as a parameter so that the request can
 * be examined and the response can be changed.
 * @param location The location to redirect to.
 */
export default function withConditionalRedirect({
  WrappedComponent,
  clientCondition,
  serverCondition,
  location
}) {
  const WithConditionalRedirectWrapper = props => {
    const router = useRouter();
    const redirectCondition = clientCondition();
    if (isBrowser() && redirectCondition) {
      router.push(location);
      return <></>;
    }
    return <WrappedComponent {...props} />;
  };

  WithConditionalRedirectWrapper.getInitialProps = async (ctx) => {
    if (!isBrowser() && ctx.res) {
      if (serverCondition(ctx)) {
        ctx.res.writeHead(302, { Location: location });
        ctx.res.end();
      }
    }

    const componentProps =
      WrappedComponent.getInitialProps &&
      (await WrappedComponent.getInitialProps(ctx));

    return { ...componentProps };
  };

  return WithConditionalRedirectWrapper;
}

Wow, that was unexpectedly complicated. We have to account for redirects both on the client and on the server (which unfortunately are performed very differently). Good thing we put this all in one place instead of duplicating the code in two HOCs.

For more details on redirects in Next.js, read Client-Side and Server-Side Redirects in Next.js.

Now let's see what our final auth HOCs will look like.

import { useIsAuthenticated } from '../providers/Auth';
import withConditionalRedirect from './withConditionalRedirect';

/**
 * Require the user to be authenticated in order to render the component.
 * If the user isn't authenticated, forward to the given URL.
 */
export default function withAuth(WrappedComponent, location='/signin') {
  return withConditionalRedirect({
    WrappedComponent,
    location,
    clientCondition: function withAuthClientCondition() {
      return !useIsAuthenticated();
    },
    serverCondition: function withAuthServerCondition(ctx) {
      return !ctx.req?.cookies.session;
    }
  });
}
import { useIsAuthenticated } from '../providers/Auth';
import withConditionalRedirect from './withConditionalRedirect';

/**
 * Require the user to be unauthenticated in order to render the component.
 * If the user is authenticated, forward to the given URL.
 */
export default function withoutAuth(WrappedComponent, location='/home') {
  return withConditionalRedirect({
    WrappedComponent,
    location,
    clientCondition: function withoutAuthClientCondition() {
      return useIsAuthenticated();
    },
    serverCondition: function withoutAuthServerCondition(ctx) {
      return !!ctx.req?.cookies.session;
    }
  });
}

You can see this architecture implemented in an example app. It's also available in TypeScript.

The architecture explained here only supports SSR. If you need to support Static Optimization then read my follow up post.

Discussion (1)

pic
Editor guide
Collapse
narcisse27 profile image
M.KURTIČ

Hi, thank you for the tutorial. Why when i change page, i have to login second time to have access to profile page ? It's like the cookie disappear immediately