Home

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:

  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.

The full code version of this example can be found here.

A live demo can be found here.

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

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.

Authsignal settings

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.

Supabase settings

Step 3: Building a Next.js app#

Create a new Next.js project:


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

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


_10
NEXT_PUBLIC_SUPABASE_URL=get-from-supabase-dashboard
_10
NEXT_PUBLIC_SUPABASE_ANON_KEY=get-from-supabase-dashboard
_10
AUTHSIGNAL_SECRET=get-from-authsignal-dashboard
_10
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.


_10
npm run dev

Step 4: Installing dependencies#

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


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

Install the Authsignal Node.js client:


_10
npm install @authsignal/node

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


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

Step 5: Initializing the Authsignal client#

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


_11
import { Authsignal } from '@authsignal/node'
_11
_11
const secret = process.env.AUTHSIGNAL_SECRET
_11
_11
if (!secret) {
_11
throw new Error('AUTHSIGNAL_SECRET is undefined')
_11
}
_11
_11
const redirectUrl = 'http://localhost:3000/api/callback'
_11
_11
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 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:


_65
import Iron from '@hapi/iron'
_65
import { Session } from '@supabase/supabase-js'
_65
import { parse, serialize } from 'cookie'
_65
import { NextApiRequest, NextApiResponse } from 'next'
_65
_65
export 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
_65
export 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
_65
export 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
_65
const isDefined = <T>(value: T | undefined): value is T => !!value
_65
_65
const TEMP_TOKEN_SECRET = process.env.TEMP_TOKEN_SECRET!
_65
const TEMP_COOKIE = 'as-mfa-cookie'
_65
const ACCESS_TOKEN_COOKIE = 'sb-access-token'
_65
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:


_44
import Link from 'next/link'
_44
import { useRouter } from 'next/router'
_44
_44
export 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:


_48
import Link from 'next/link'
_48
import { useRouter } from 'next/router'
_48
_48
export 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:


_50
import { getUser, User, withPageAuth } from '@supabase/auth-helpers-nextjs'
_50
import { GetServerSideProps } from 'next'
_50
import { useRouter } from 'next/router'
_50
import { authsignal } from '../lib/authsignal'
_50
_50
interface Props {
_50
user: User
_50
isEnrolled: boolean
_50
}
_50
_50
export 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
_50
export 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:


_47
main {
_47
min-height: 100vh;
_47
display: flex;
_47
flex: 1;
_47
flex-direction: column;
_47
justify-content: center;
_47
align-items: center;
_47
}
_47
_47
section,
_47
form {
_47
display: flex;
_47
flex-direction: column;
_47
min-width: 300px;
_47
}
_47
_47
button {
_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
_47
input {
_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
_47
a {
_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:


_27
import { supabaseClient } from '@supabase/auth-helpers-nextjs'
_27
import { NextApiRequest, NextApiResponse } from 'next'
_27
import { authsignal } from '../../lib/authsignal'
_27
import { setAuthCookie, setTempCookie } from '../../lib/cookies'
_27
_27
export 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:


_19
import { supabaseClient } from '@supabase/auth-helpers-nextjs'
_19
import { Session } from '@supabase/supabase-js'
_19
import { NextApiRequest, NextApiResponse } from 'next'
_19
import { setAuthCookie } from '../../lib/cookies'
_19
_19
export 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
_19
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:


_10
import { supabaseClient } from '@supabase/auth-helpers-nextjs'
_10
import { NextApiRequest, NextApiResponse } from 'next'
_10
_10
export 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:


_21
import { getUser, withApiAuth } from '@supabase/auth-helpers-nextjs'
_21
import { NextApiRequest, NextApiResponse } from 'next'
_21
import { authsignal } from '../../lib/authsignal'
_21
_21
export 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:


_19
import { NextApiRequest, NextApiResponse } from 'next'
_19
import { authsignal } from '../../lib/authsignal'
_19
import { getSessionFromTempCookie, setAuthCookie } from '../../lib/cookies'
_19
_19
export 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.