Authsignal
This guide shows how to integrate Authsignal with Next.js and Supabase in order to add an MFA step after sign-in.
The user flow is as follows:
- The user enters their email and password to sign in
- If the user has set up MFA, they're prompted to complete an MFA challenge (via Authenticator App) in order to complete sign-in
- 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.
The full code version of this example can be found here.
A live demo can be found here.
How it works
- A sign-in form posts email and password to the Next.js API route
/api/sign-in
- The
signIn
API route calls the Supabase client'ssignInWithEmail
method and gets back a session object - The
signIn
API route then calls the Authsignal client'strack
method to determine if an MFA challenge is required - If a challenge is required, the
signIn
API route saves the session object in a temporary encrypted cookie and redirects to Authsignal - Once the challenge is completed, Authsignal redirects back to
/api/callback
which retrieves the session and sets the Supabase auth cookies - The
callback
API route then redirects to the index page which is protected with Supabase'swithPageAuth
wrapper aroundgetServerSideProps
Step 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, to configure the sign-in action to always challenge, go here and set the default action outcome to CHALLENGE
and click save.
Step 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.
Step 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
Step 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
Step 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:3000/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.
Step 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 cookiegetSessionFromTempCookie
decrypts and parses this session data back from the cookiesetAuthCookie
sets the Supabase auth cookies (access_token
andrefresh_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";
Step 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;
}
Step 8: Adding the API routes
Now we'll replace the existing api routes in /pages/api/
with 5 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 MFA page 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 { success } = await authsignal.validateChallenge({ token });
if (success) {
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.
Resources
- To learn more about Authsignal take a look at the API Documentation.
- You can customize the look and feel of the Authsignal Prebuilt MFA page here.