Easily Password Protect NextJS Pages With Iron Session

Let’s say you want to set up a simple password protected page (or a bunch of pages) just for you in your NextJS application. It is very easy to do with the help of encrypted cookie and a small library called iron-session, a Node.js stateless session utility. Most of the tutorials in this library focus on setting up user authentication for as many users as you want at some point, but this tutorial will teach you how to lock certain server pages behind a password. I’ve used it before to create small admin pages that I want to keep private but I don’t want to set up full user authentication.

to start

First things first, assuming you have a NextJS application, if you don’t follow the instructions here to set up one.

Then you’ll need to install iron-op and swr (although you probably can’t use swr if you want to cut it, it’s just a good one).

npm install -S iron-session swr
yarn add iron-session swr
enter fullscreen mode

exit fullscreen mode

make .env.local And .env.local.example file at the root of your project. Make sure .env.local is added to your .gitignore (It should be by default in a vanilla NextJS setup) This file would look like this:

PASSWORD=<anything you want to secure page>
SECRET_COOKIE_PASSWORD=<anything at least 32 characters long>
enter fullscreen mode

exit fullscreen mode

Create a 32-character password for SECRET (you won’t need to remember it), then create a password you can remember for the password (this is what you’ll enter on the page to access your secure route).

You would then create some new files:

/utils/session.js.

import { withIronSessionApiRoute, withIronSessionSsr } from 'iron-session/next';

const sessionOptions = {
  password: process.env.SECRET_COOKIE_PASSWORD,
  cookieName: 'next-iron-session/examples/next.js',
  // secure: true should be used in production (HTTPS) but can't be used in development (HTTP)
  cookieOptions: {
    secure: process.env.NODE_ENV === 'production',
  },
};

export function withSessionRoute(handler) {
  return withIronSessionApiRoute(handler, sessionOptions);
}

export function withSessionSsr(handler) {
  return withIronSessionSsr(handler, sessionOptions);
}
enter fullscreen mode

exit fullscreen mode

/pages/api/login.js

import { withSessionRoute } from '@utils/session';

export default withSessionRoute(async (req, res) => {
  const { password } = await req.body;

  try {
    if (password === process.env.PASSWORD) {
      const user = { isLoggedIn: true };
      req.session.user = user;
      await req.session.save();
      res.json(user);
    } else {
      const user = { isLoggedIn: false };
      res.json(user);
    }
  } catch (error) {
    const { response: fetchResponse } = error;
    res.status(fetchResponse?.status || 500).json(error.data);
  }
});
enter fullscreen mode

exit fullscreen mode

/pages/api/logout.js

import { withSessionRoute } from '@utils/session';

export default withSessionRoute(async (req, res) => {
  req.session.destroy();
  res.json({ isLoggedIn: false });
});

/pages/api/user.js

import { withSessionRoute } from '@utils/session';

export default withSessionRoute(async (req, res) => {
  const user = req.session.get('user');

  if (user) {
    // in a real world application you might read the user id from the session and then do a database request
    // to get more information on the user if needed
    res.json({
      isLoggedIn: true,
      ...user,
    });
  } else {
    res.json({
      isLoggedIn: false,
    });
  }
});
enter fullscreen mode

exit fullscreen mode

/utils/useUser.js

import { useEffect } from 'react';
import Router from 'next/router';
import useSWR from 'swr';

export default function useUser({
  redirectTo = false,
  redirectIfFound = false,
} = {}) {
  const { data: user, mutate: mutateUser } = useSWR('/api/user');

  useEffect(() => {
    // if no redirect needed, just return (example: already on /dashboard)
    // if user data not yet there (fetch in progress, logged in or not) then don't do anything yet
    if (!redirectTo || !user) return;

    if (
      // If redirectTo is set, redirect if the user was not found.
      (redirectTo && !redirectIfFound && !user?.isLoggedIn) ||
      // If redirectIfFound is also set, redirect if the user was found
      (redirectIfFound && user?.isLoggedIn)
    ) {
      Router.push(redirectTo);
    }
  }, [user, redirectIfFound, redirectTo]);

  return { user, mutateUser };
}
enter fullscreen mode

exit fullscreen mode

/pages/login.js

import { useState } from 'react';
import useUser from '../utils/useUser';

export default function Login() {
  // here we just check if user is already logged in and redirect to admin
  const { mutateUser } = useUser({
    redirectTo: '/admin',
    redirectIfFound: true,
  });

  const [errorMsg, setErrorMsg] = useState('');

  async function handleSubmit(e) {
    e.preventDefault();

    const body = {
      password: e.currentTarget.password.value,
    };

    const userData = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });

    const user = await userData.json();

    try {
      await mutateUser(user);
    } catch (error) {
      console.error('An unexpected error happened:', error);
      setErrorMsg(error.data.message);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Enter password
        <input type='password' name='password' required />
      </label>

      <button type='submit'>Login</button>

      {errorMsg && <p>{errorMsg}</p>}
    </form>
  );
}
enter fullscreen mode

exit fullscreen mode

These pages and APIs form the backbone for logging in and logging out, and a form page on which you can enter your password. You can see that the login api is doing a simple comparison of the password in the route request, which you set in your env file.

The last thing you need is the route (or routes) you want to secure. The example here does this with server side props (SSR) but you can also call API routes from the client side. it will redirect /login If the user is not returned from withSessionSsr Show handler or page if you are logged in.

pages/admin.js

import { withSessionSsr } from '@utils/session';

export default function Admin() {
  // Users will never see this unless they're logged in.
  return <h1>Secure page</h1>;
}

export const getServerSideProps = withSessionSsr(async function ({ req, res }) {
  const user = req.session.user;

  if (user === undefined) {
    res.setHeader('location', '/login');
    res.statusCode = 302;
    res.end();
    return { props: {} };
  }

  // You can return data here from a database knowing only authenticated users (you) will see it.
  return { props: {} };
});
enter fullscreen mode

exit fullscreen mode

Encrypted Cookies Are Awesome and the People Behind iron-session Crazy are smart. This will give you a simple and functional secure page that you can access with your password. You are vulnerable to brute force here as an FYI. You’ll have to do something else to mitigate this, but if you name your pages something other than login and admin, you can at least be a little fuzzy and get a little security through it.

Don’t forget to add two ENV versions to your server when you deploy.

Let me know if you have any questions or suggestions modifications by contacting me on Twitter @itwasmattgregg.

Leave a Comment