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:
_10npx create-next-app --typescript supabase-authsignal-example_10cd supabase-authsignal-example
Create a .env.local
file and enter the following values:
_10NEXT_PUBLIC_SUPABASE_URL=get-from-supabase-dashboard_10NEXT_PUBLIC_SUPABASE_ANON_KEY=get-from-supabase-dashboard_10AUTHSIGNAL_SECRET=get-from-authsignal-dashboard_10TEMP_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
.
_10npm run dev
Step 4: Installing dependencies#
Install the Supabase client and Auth helpers for Next.js:
_10npm install @supabase/supabase-js @supabase/auth-helpers-nextjs
Install the Authsignal Node.js client:
_10npm install @authsignal/node
Finally install 2 packages to help encrypt and serialize session data in cookies:
_10npm install @hapi/iron cookie_10npm install --save-dev @types/cookie
Step 5: Initializing the Authsignal client#
Add the following code to /lib/authsignal.ts
:
_11import { Authsignal } from '@authsignal/node'_11_11const secret = process.env.AUTHSIGNAL_SECRET_11_11if (!secret) {_11 throw new Error('AUTHSIGNAL_SECRET is undefined')_11}_11_11const redirectUrl = 'http://localhost:3000/api/callback'_11_11export 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
:
_65import Iron from '@hapi/iron'_65import { Session } from '@supabase/supabase-js'_65import { parse, serialize } from 'cookie'_65import { NextApiRequest, NextApiResponse } from 'next'_65_65export async function setTempCookie(session: Session, res: NextApiResponse) {_65 const token = await Iron.seal(session, TEMP_TOKEN_SECRET, Iron.defaults)_65_65 const cookie = serialize(TEMP_COOKIE, token, {_65 maxAge: session.expires_in,_65 httpOnly: true,_65 secure: process.env.NODE_ENV === 'production',_65 path: '/',_65 sameSite: 'lax',_65 })_65_65 res.setHeader('Set-Cookie', cookie)_65}_65_65export async function getSessionFromTempCookie(req: NextApiRequest): Promise<Session | undefined> {_65 const cookie = req.headers.cookie as string_65_65 const cookies = parse(cookie ?? '')_65_65 const tempCookie = cookies[TEMP_COOKIE]_65_65 if (!tempCookie) {_65 return undefined_65 }_65_65 const session = await Iron.unseal(tempCookie, TEMP_TOKEN_SECRET, Iron.defaults)_65_65 return session_65}_65_65export function setAuthCookie(session: Session, res: NextApiResponse) {_65 const { access_token, refresh_token, expires_in } = session_65_65 const authCookies = [_65 { name: ACCESS_TOKEN_COOKIE, value: access_token },_65 refresh_token ? { name: REFRESH_TOKEN_COOKIE, value: refresh_token } : undefined,_65 ]_65 .filter(isDefined)_65 .map(({ name, value }) =>_65 serialize(name, value, {_65 maxAge: expires_in,_65 httpOnly: true,_65 secure: process.env.NODE_ENV === 'production',_65 path: '/',_65 sameSite: 'lax',_65 })_65 )_65_65 // Also clear the temp cookie_65 const updatedCookies = [...authCookies, serialize(TEMP_COOKIE, '', { maxAge: -1, path: '/' })]_65_65 res.setHeader('Set-Cookie', updatedCookies)_65}_65_65const isDefined = <T>(value: T | undefined): value is T => !!value_65_65const TEMP_TOKEN_SECRET = process.env.TEMP_TOKEN_SECRET!_65const TEMP_COOKIE = 'as-mfa-cookie'_65const ACCESS_TOKEN_COOKIE = 'sb-access-token'_65const 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
:
_44import Link from 'next/link'_44import { useRouter } from 'next/router'_44_44export default function SignUpPage() {_44 const router = useRouter()_44_44 return (_44 <main>_44 <form_44 onSubmit={async (e) => {_44 e.preventDefault()_44_44 const target = e.target as typeof e.target & {_44 email: { value: string }_44 password: { value: string }_44 }_44_44 const email = target.email.value_44 const password = target.password.value_44_44 await fetch('/api/sign-up', {_44 method: 'POST',_44 headers: { 'Content-Type': 'application/json' },_44 body: JSON.stringify({ email, password }),_44 }).then((res) => res.json())_44_44 router.push('/')_44 }}_44 >_44 <label htmlFor="email">Email</label>_44 <input id="email" type="email" name="email" required />_44 <label htmlFor="password">Password</label>_44 <input id="password" type="password" name="password" required />_44 <button type="submit">Sign up</button>_44 </form>_44 <div>_44 {'Already have an account? '}_44 <Link href="sign-in">_44 <a>Sign in</a>_44 </Link>_44 </div>_44 </main>_44 )_44}
Then add the following code to /pages/sign-in.tsx
:
_48import Link from 'next/link'_48import { useRouter } from 'next/router'_48_48export default function SignInPage() {_48 const router = useRouter()_48_48 return (_48 <main>_48 <form_48 onSubmit={async (e) => {_48 e.preventDefault()_48_48 const target = e.target as typeof e.target & {_48 email: { value: string }_48 password: { value: string }_48 }_48_48 const email = target.email.value_48 const password = target.password.value_48_48 const { state, mfaUrl } = await fetch('/api/sign-in', {_48 method: 'POST',_48 headers: { 'Content-Type': 'application/json' },_48 body: JSON.stringify({ email, password }),_48 }).then((res) => res.json())_48_48 if (state === 'CHALLENGE_REQUIRED') {_48 window.location.href = mfaUrl_48 } else {_48 router.push('/')_48 }_48 }}_48 >_48 <label htmlFor="email">Email</label>_48 <input id="email" type="email" name="email" required />_48 <label htmlFor="password">Password</label>_48 <input id="password" type="password" name="password" required />_48 <button type="submit">Sign in</button>_48 </form>_48 <div>_48 {"Don't have an account? "}_48 <Link href="sign-up">_48 <a>Sign up</a>_48 </Link>_48 </div>_48 </main>_48 )_48}
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:
_50import { getUser, User, withPageAuth } from '@supabase/auth-helpers-nextjs'_50import { GetServerSideProps } from 'next'_50import { useRouter } from 'next/router'_50import { authsignal } from '../lib/authsignal'_50_50interface Props {_50 user: User_50 isEnrolled: boolean_50}_50_50export const getServerSideProps: GetServerSideProps<Props> = withPageAuth({_50 redirectTo: '/sign-in',_50 async getServerSideProps(ctx) {_50 const { user } = await getUser(ctx)_50_50 const { isEnrolled } = await authsignal.getUser({ userId: user.id })_50_50 return {_50 props: { user, isEnrolled },_50 }_50 },_50})_50_50export default function HomePage({ user, isEnrolled }: Props) {_50 const router = useRouter()_50_50 return (_50 <main>_50 <section>_50 <div> Signed in as: {user?.email}</div>_50 <button_50 onClick={async (e) => {_50 e.preventDefault()_50_50 const { mfaUrl } = await fetch('/api/mfa', {_50 method: 'POST',_50 headers: { 'Content-Type': 'application/json' },_50 body: JSON.stringify({ isEnrolled }),_50 }).then((res) => res.json())_50_50 window.location.href = mfaUrl_50 }}_50 >_50 {isEnrolled ? 'Manage MFA settings' : 'Set up MFA'}_50 </button>_50 <button onClick={() => router.push('/api/sign-out')}>Sign out</button>_50 </section>_50 </main>_50 )_50}
Optional: To make things look a bit nicer, you can add the following to /styles/globals.css
:
_47main {_47 min-height: 100vh;_47 display: flex;_47 flex: 1;_47 flex-direction: column;_47 justify-content: center;_47 align-items: center;_47}_47_47section,_47form {_47 display: flex;_47 flex-direction: column;_47 min-width: 300px;_47}_47_47button {_47 cursor: pointer;_47 font-weight: 500;_47 line-height: 1;_47 border-radius: 6px;_47 border: none;_47 background-color: #24b47e;_47 color: #fff;_47 padding: 0 15px;_47 height: 40px;_47 margin: 10px 0;_47 transition: background-color 0.15s, color 0.15s;_47}_47_47input {_47 outline: none;_47 font-family: inherit;_47 font-weight: 400;_47 background-color: #fff;_47 border-radius: 6px;_47 color: #1d1d1d;_47 border: 1px solid #e8e8e8;_47 padding: 0 15px;_47 margin: 5px 0;_47 height: 40px;_47}_47_47a {_47 color: #24b47e;_47 cursor: pointer;_47}
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
:
_27import { supabaseClient } from '@supabase/auth-helpers-nextjs'_27import { NextApiRequest, NextApiResponse } from 'next'_27import { authsignal } from '../../lib/authsignal'_27import { setAuthCookie, setTempCookie } from '../../lib/cookies'_27_27export default async function signIn(req: NextApiRequest, res: NextApiResponse) {_27 const { email, password } = req.body_27_27 const { data, error } = await supabaseClient.auth.api.signInWithEmail(email, password)_27_27 if (error || !data?.user) {_27 return res.send({ error })_27 }_27_27 const { state, url: mfaUrl } = await authsignal.track({_27 action: 'signIn',_27 userId: data.user.id,_27 })_27_27 if (state === 'CHALLENGE_REQUIRED') {_27 await setTempCookie(data, res)_27 } else {_27 setAuthCookie(data, res)_27 }_27_27 res.send({ state, mfaUrl })_27}
Then to handle new sign-ups add the following to /pages/api/sign-up.ts
:
_19import { supabaseClient } from '@supabase/auth-helpers-nextjs'_19import { Session } from '@supabase/supabase-js'_19import { NextApiRequest, NextApiResponse } from 'next'_19import { setAuthCookie } from '../../lib/cookies'_19_19export default async function signUp(req: NextApiRequest, res: NextApiResponse) {_19 const { email, password } = req.body_19_19 const { data, error } = await supabaseClient.auth.api.signUpWithEmail(email, password)_19_19 if (error || !isSession(data)) {_19 res.send({ error })_19 } else {_19 setAuthCookie(data, res)_19 res.send({ data })_19 }_19}_19_19const 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
:
_10import { supabaseClient } from '@supabase/auth-helpers-nextjs'_10import { NextApiRequest, NextApiResponse } from 'next'_10_10export default async function signOut(req: NextApiRequest, res: NextApiResponse) {_10 supabaseClient.auth.api.deleteAuthCookie(req, res, { redirectTo: '/sign-in' })_10}
To handle the user's actions to set up MFA or manage their existing MFA settings, add the following to /pages/api/mfa.ts
:
_21import { getUser, withApiAuth } from '@supabase/auth-helpers-nextjs'_21import { NextApiRequest, NextApiResponse } from 'next'_21import { authsignal } from '../../lib/authsignal'_21_21export default withApiAuth(async function mfa(req: NextApiRequest, res: NextApiResponse) {_21 if (req.method !== 'POST') {_21 return res.status(405).send({ message: 'Only POST requests allowed' })_21 }_21_21 const { user } = await getUser({ req, res })_21_21 const { isEnrolled } = req.body_21_21 const { url: mfaUrl } = await authsignal.track({_21 action: isEnrolled ? 'manageSettings' : 'enroll',_21 userId: user.id,_21 redirectToSettings: isEnrolled,_21 })_21_21 res.send({ mfaUrl })_21})
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
:
_19import { NextApiRequest, NextApiResponse } from 'next'_19import { authsignal } from '../../lib/authsignal'_19import { getSessionFromTempCookie, setAuthCookie } from '../../lib/cookies'_19_19export default async function callback(req: NextApiRequest, res: NextApiResponse) {_19 const token = req.query.token as string_19_19 const { success } = await authsignal.validateChallenge({ token })_19_19 if (success) {_19 const session = await getSessionFromTempCookie(req)_19_19 if (session) {_19 setAuthCookie(session, res)_19 }_19 }_19_19 res.redirect('/')_19}
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.