Easily Password Protect NextJS pages with Iron Session

October 22, 2022

Say you want to set up a simple password protected page (or a bunch of pages), just for yourself, in your NextJS application. It’s super simple to do with an encrypted cookie and the help of a little library called iron-session, a Node.js stateless session utility. Most of the tutorials for this library focus on setting up user auth for multiple users which you may want to at some point but this tutorial will teach you how to lock certain server pages behind a password. I’ve used this before to create little admin pages that I want to keep private but I don’t want to set up full user auth.

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

Then you’ll need to install iron-session and swr (although you can probably not use swr if you want to cut it out, it’s just a nice to have).

npm install -S iron-session swr
yarn add iron-session swr

Create a .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 will look like this:

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

Create a password 32 characters long for the SECRET (you will not need to remember this), then create a password you can remember for PASSWORD (this is what you’ll enter on the page to access your secure route).

You will then create a few 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);
}

/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);
  }
});

/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,
    });
  }
});

/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 };
}

/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>
  );
}

These pages and API routes create the backbone to log in and log out, and a form page you can enter your password on. You can see the login API route is doing a simple comparison of the password in the request with the password you set in your ENV file.

The last thing you need is the route (or routes) you want to secure. The example here does it with server side props (SSR) but you could also call the api route from the client side. This will redirect to /login if the user is not returned from the withSessionSsr handler or show the 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: {} };
});

Encrypted cookies are pretty awesome and the people behind iron-session are insanely smart. This will get you a simple and functional secure page that you can access with your password. You are vulnerable to brute force here just as an FYI. You’ll have to do something else to mitigate that but if you name your pages something other than login and admin, you can at least be a bit obscure and get slight security through that.

Don’t forget to add the two ENV vars to your server as well when you deploy.

Let me know if you have questions or suggested modifications by contacting me on twitter @itwasmattgregg.


Written by Matt Gregg, a UI engineer who lives and works in Minneapolis, MN

Have something to say about this post? Tweet at me

matt@codegregg.com