This guide shows how to integrate Authsignal with Next.js and Supabase in order to add an MFA after sign-in.

The user flow is as follows:

  1. The user enters their email and password to sign in
  2. If the user has set up MFA, they’re prompted to complete an MFA challenge (via Authenticator App) in order to complete sign-in
  3. If the user has not set up MFA, they’re signed in immediately and will see a button to set up MFA

The approach uses a temporary encrypted cookie to ensure that the Supabase auth cookies - access_token and refresh_token - are only set if the MFA challenge was successful. Session data is encrypted using @hapi/iron.

Full code example

Live demo

How it works

  1. A sign-in form posts email and password to the Next.js API route /api/sign-in
  2. The signIn API route calls the Supabase client’s signInWithEmail method and gets back a session object
  3. The signIn API route then calls the Authsignal client’s track method to determine if an MFA challenge is required
  4. If a challenge is required, the signIn API route saves the session object in a temporary encrypted cookie and redirects to Authsignal
  5. Once the challenge is completed, Authsignal redirects back to /api/callback which retrieves the session and sets the Supabase auth cookies
  6. The callback API route then redirects to the index page which is protected with Supabase’s withPageAuth wrapper around getServerSideProps

1: Configuring an Authsignal tenant

Go to the Authsignal Portal and create a new project and tenant.

You will also need to enable at least one authenticator for your tenant - for example Authenticator Apps.

Finally, go the sign-in action settings and set the default action outcome to Challenge and click Save.

2: Creating a Supabase project

From your Supabase dashboard, click New project.

Enter a Name for your Supabase project and enter or generate a secure Database Password, then click Create new project.

Once your project is created go to Authentication -> Settings -> Auth Providers and ensure Enable Email provider is checked and that Confirm Email is unchecked.

3: Building a Next.js app

Create a new Next.js project:

npx create-next-app --typescript supabase-authsignal-example
cd supabase-authsignal-example

Create a .env.local file and enter the following values:

NEXT_PUBLIC_SUPABASE_URL=get-from-supabase-dashboard
NEXT_PUBLIC_SUPABASE_ANON_KEY=get-from-supabase-dashboard
AUTHSIGNAL_SECRET=get-from-authsignal-dashboard
TEMP_TOKEN_SECRET=this-is-a-secret-value-with-at-least-32-characters

Supabase values can be found under Settings > API for your project.

Authsignal values can be found under Settings > API Keys for your tenant.

TEMP_TOKEN_SECRET is used to encrypt the temporary cookie. Set it to a random 32 character length string.

Restart your Next.js development server to read in the new values from .env.local.

npm run dev

4: Installing dependencies

Install the Supabase client and Auth helpers for Next.js:

npm install @supabase/supabase-js @supabase/auth-helpers-nextjs

Install the Authsignal Node.js client:

npm install @authsignal/node

Finally install 2 packages to help encrypt and serialize session data in cookies:

npm install @hapi/iron cookie
npm install --save-dev @types/cookie

5: Initializing the Authsignal client

Add the following code to /lib/authsignal.ts:

import { Authsignal } from "@authsignal/node";

const secret = process.env.AUTHSIGNAL_SECRET;

if (!secret) {
  throw new Error("AUTHSIGNAL_SECRET is undefined");
}

const redirectUrl = "http://localhost:3001/api/callback";

export const authsignal = new Authsignal({ secret, redirectUrl });

The redirectUrl here is a Next.js API route which Authsignal will redirect back to after an MFA challenge. We’ll implement this below.

6: Managing session data in cookies

Next we will add some helper functions for managing cookies:

  • setTempCookie encrypts and serializes the Supabase session data and sets it in a temporary cookie
  • getSessionFromTempCookie decrypts and parses this session data back from the cookie
  • setAuthCookie sets the Supabase auth cookies (access_token and refresh_token) and clears the temporary cookie

Add the following code to /lib/cookies.ts:

import Iron from "@hapi/iron";
import { Session } from "@supabase/supabase-js";
import { parse, serialize } from "cookie";
import { NextApiRequest, NextApiResponse } from "next";

export async function setTempCookie(session: Session, res: NextApiResponse) {
  const token = await Iron.seal(session, TEMP_TOKEN_SECRET, Iron.defaults);

  const cookie = serialize(TEMP_COOKIE, token, {
    maxAge: session.expires_in,
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    sameSite: "lax",
  });

  res.setHeader("Set-Cookie", cookie);
}

export async function getSessionFromTempCookie(
  req: NextApiRequest
): Promise<Session | undefined> {
  const cookie = req.headers.cookie as string;

  const cookies = parse(cookie ?? "");

  const tempCookie = cookies[TEMP_COOKIE];

  if (!tempCookie) {
    return undefined;
  }

  const session = await Iron.unseal(
    tempCookie,
    TEMP_TOKEN_SECRET,
    Iron.defaults
  );

  return session;
}

export function setAuthCookie(session: Session, res: NextApiResponse) {
  const { access_token, refresh_token, expires_in } = session;

  const authCookies = [
    { name: ACCESS_TOKEN_COOKIE, value: access_token },
    refresh_token
      ? { name: REFRESH_TOKEN_COOKIE, value: refresh_token }
      : undefined,
  ]
    .filter(isDefined)
    .map(({ name, value }) =>
      serialize(name, value, {
        maxAge: expires_in,
        httpOnly: true,
        secure: process.env.NODE_ENV === "production",
        path: "/",
        sameSite: "lax",
      })
    );

  // Also clear the temp cookie
  const updatedCookies = [
    ...authCookies,
    serialize(TEMP_COOKIE, "", { maxAge: -1, path: "/" }),
  ];

  res.setHeader("Set-Cookie", updatedCookies);
}

const isDefined = <T>(value: T | undefined): value is T => !!value;

const TEMP_TOKEN_SECRET = process.env.TEMP_TOKEN_SECRET!;
const TEMP_COOKIE = "as-mfa-cookie";
const ACCESS_TOKEN_COOKIE = "sb-access-token";
const REFRESH_TOKEN_COOKIE = "sb-refresh-token";

Then add the following to /lib/index.ts:

export * from "./cookies";

7: Building the UI

We will add some form components for signing in and signing up as well as a basic home page.

Add the following code to /pages/sign-up.tsx:

import Link from "next/link";
import { useRouter } from "next/router";

export default function SignUpPage() {
  const router = useRouter();

  return (
    <main>
      <form
        onSubmit={async (e) => {
          e.preventDefault();

          const target = e.target as typeof e.target & {
            email: { value: string };
            password: { value: string };
          };

          const email = target.email.value;
          const password = target.password.value;

          await fetch("/api/sign-up", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ email, password }),
          }).then((res) => res.json());

          router.push("/");
        }}
      >
        <label htmlFor="email">Email</label>
        <input id="email" type="email" name="email" required />
        <label htmlFor="password">Password</label>
        <input id="password" type="password" name="password" required />
        <button type="submit">Sign up</button>
      </form>
      <div>
        {"Already have an account? "}
        <Link href="sign-in">
          <a>Sign in</a>
        </Link>
      </div>
    </main>
  );
}

Then add the following code to /pages/sign-in.tsx:

import Link from "next/link";
import { useRouter } from "next/router";

export default function SignInPage() {
  const router = useRouter();

  return (
    <main>
      <form
        onSubmit={async (e) => {
          e.preventDefault();

          const target = e.target as typeof e.target & {
            email: { value: string };
            password: { value: string };
          };

          const email = target.email.value;
          const password = target.password.value;

          const { state, mfaUrl } = await fetch("/api/sign-in", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ email, password }),
          }).then((res) => res.json());

          if (state === "CHALLENGE_REQUIRED") {
            window.location.href = mfaUrl;
          } else {
            router.push("/");
          }
        }}
      >
        <label htmlFor="email">Email</label>
        <input id="email" type="email" name="email" required />
        <label htmlFor="password">Password</label>
        <input id="password" type="password" name="password" required />
        <button type="submit">Sign in</button>
      </form>
      <div>
        {"Don't have an account? "}
        <Link href="sign-up">
          <a>Sign up</a>
        </Link>
      </div>
    </main>
  );
}

Now we will use Supabase’s withPageAuth wrapper around getServerSideProps to make the home page require authentication via SSR. Replace the existing code in /pages/index.tsx with the following:

import { getUser, User, withPageAuth } from "@supabase/auth-helpers-nextjs";
import { GetServerSideProps } from "next";
import { useRouter } from "next/router";
import { authsignal } from "../lib/authsignal";

interface Props {
  user: User;
  isEnrolled: boolean;
}

export const getServerSideProps: GetServerSideProps<Props> = withPageAuth({
  redirectTo: "/sign-in",
  async getServerSideProps(ctx) {
    const { user } = await getUser(ctx);

    const { isEnrolled } = await authsignal.getUser({ userId: user.id });

    return {
      props: { user, isEnrolled },
    };
  },
});

export default function HomePage({ user, isEnrolled }: Props) {
  const router = useRouter();

  return (
    <main>
      <section>
        <div> Signed in as: {user?.email}</div>
        <button
          onClick={async (e) => {
            e.preventDefault();

            const { mfaUrl } = await fetch("/api/mfa", {
              method: "POST",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ isEnrolled }),
            }).then((res) => res.json());

            window.location.href = mfaUrl;
          }}
        >
          {isEnrolled ? "Manage MFA settings" : "Set up MFA"}
        </button>
        <button onClick={() => router.push("/api/sign-out")}>Sign out</button>
      </section>
    </main>
  );
}

Optional: To make things look a bit nicer, you can add the following to /styles/globals.css:

main {
  min-height: 100vh;
  display: flex;
  flex: 1;
  flex-direction: column;
  justify-content: center;
  align-items: center;
}

section,
form {
  display: flex;
  flex-direction: column;
  min-width: 300px;
}

button {
  cursor: pointer;
  font-weight: 500;
  line-height: 1;
  border-radius: 6px;
  border: none;
  background-color: #24b47e;
  color: #fff;
  padding: 0 15px;
  height: 40px;
  margin: 10px 0;
  transition: background-color 0.15s, color 0.15s;
}

input {
  outline: none;
  font-family: inherit;
  font-weight: 400;
  background-color: #fff;
  border-radius: 6px;
  color: #1d1d1d;
  border: 1px solid #e8e8e8;
  padding: 0 15px;
  margin: 5px 0;
  height: 40px;
}

a {
  color: #24b47e;
  cursor: pointer;
}

8: Adding the API routes

Now we’ll replace the existing api routes in /pages/api/ with 4 new routes:

  • /sign-in.ts: handles signing in with Supabase and initiating the MFA challenge with Authsignal
  • /sign-up.ts: handles signing up with Supabase
  • /sign-out.ts: clears the Supabase auth cookies and signs the user out
  • /mfa.ts: handles the user’s attempt to set up MFA or to manage their existing MFA settings
  • /callback.ts: handles completing the MFA challenge with Authsignal

Add the following code to /pages/api/sign-in.ts:

import { supabaseClient } from "@supabase/auth-helpers-nextjs";
import { NextApiRequest, NextApiResponse } from "next";
import { authsignal } from "../../lib/authsignal";
import { setAuthCookie, setTempCookie } from "../../lib/cookies";

export default async function signIn(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { email, password } = req.body;

  const { data, error } = await supabaseClient.auth.api.signInWithEmail(
    email,
    password
  );

  if (error || !data?.user) {
    return res.send({ error });
  }

  const { state, url: mfaUrl } = await authsignal.track({
    action: "signIn",
    userId: data.user.id,
  });

  if (state === "CHALLENGE_REQUIRED") {
    await setTempCookie(data, res);
  } else {
    setAuthCookie(data, res);
  }

  res.send({ state, mfaUrl });
}

Then to handle new sign-ups add the following to /pages/api/sign-up.ts:

import { supabaseClient } from "@supabase/auth-helpers-nextjs";
import { Session } from "@supabase/supabase-js";
import { NextApiRequest, NextApiResponse } from "next";
import { setAuthCookie } from "../../lib/cookies";

export default async function signUp(req: NextApiRequest, res: NextApiResponse) {
  const { email, password } = req.body;

  const { data, error } = await supabaseClient.auth.api.signUpWithEmail(
    email,
    password
  );

  if (error || !isSession(data)) {
    res.send({ error });
  } else {
    setAuthCookie(data, res);
    res.send({ data });
  }
}

const isSession = (data: any): data is Session => !!data?.access_token;

To clear the auth cookies on sign-out add the following to /pages/api/sign-out.ts:

import { supabaseClient } from "@supabase/auth-helpers-nextjs";
import { NextApiRequest, NextApiResponse } from "next";

export default async function signOut(
  req: NextApiRequest,
  res: NextApiResponse
) {
  supabaseClient.auth.api.deleteAuthCookie(req, res, {
    redirectTo: "/sign-in",
  });
}

To handle the user’s actions to set up MFA or manage their existing MFA settings, add the following to /pages/api/mfa.ts:

import { getUser, withApiAuth } from "@supabase/auth-helpers-nextjs";
import { NextApiRequest, NextApiResponse } from "next";
import { authsignal } from "../../lib/authsignal";

export default withApiAuth(async function mfa(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== "POST") {
    return res.status(405).send({ message: "Only POST requests allowed" });
  }

  const { user } = await getUser({ req, res });

  const { isEnrolled } = req.body;

  const { url: mfaUrl } = await authsignal.track({
    action: isEnrolled ? "manageSettings" : "enroll",
    userId: user.id,
    redirectToSettings: isEnrolled,
  });

  res.send({ mfaUrl });
});

Because the user should be authenticated with Supabase to set up or manage MFA, we can use Supabase’s withApiAuth wrapper to protect this route.

The redirectToSettings param specifies whether the user should be redirected to the Challenge UI settings panel after a challenge, rather than redirecting them immediately back to the application.

Finally we need a route to handle the redirect back from Authsignal after an MFA challenge. Add the following to /pages/api/callback.ts:

import { NextApiRequest, NextApiResponse } from "next";
import { authsignal } from "../../lib/authsignal";
import { getSessionFromTempCookie, setAuthCookie } from "../../lib/cookies";

export default async function callback(req: NextApiRequest, res: NextApiResponse) {
  const token = req.query.token as string;

  const { isValid } = await authsignal.validateChallenge({ token });

  if (isValid) {
    const session = await getSessionFromTempCookie(req);
    if (session) {
      setAuthCookie(session, res);
    }
  }

  res.redirect("/");
}

That’s it! You should now be able to sign up a new user and set up MFA.

Then if you sign out, you’ll be prompted to complete an MFA challenge when signing back in again.