Radzion
14 min readOct 8, 2023

--

Watch on YouTube | 🐙 GitHub | 🎮 Demo

Introduction to Magic Link Email Authentication

In this post, I will demonstrate a basic implementation of magic link email authentication for NextJS + NodeJS applications. This authentication method is convenient and efficient, simplifying the process for both users and developers by eliminating the need to deal with passwords. While this explanation covers both front-end and back-end basics, remember that this is a simplified solution. If your application manages sensitive information or financial transactions, you would find it prudent to either enhance the security measures detailed here, or employ a third-party service. Our discussion relies on the Increaser codebase. It’s kept in a private repository, but rest assured; all the necessary code snippets will be included directly in this blog post. Furthermore, you can find all the reusable hooks, components, and utility tools in the ReactKit repository.

Sign up page at Increaser

Front-End Implementation: Setup and Data Collection

Let’s begin with the front-end. I use the AuthProviders component on both sign-in and sign-up pages, which displays Facebook and Google authentication options as well as an EmailAuthForm. You can learn more about our OAuth implementation in my other post.

import { VStack } from "@increaser/ui/ui/Stack"

import { OptionsDivider } from "../OptionsDivider"
import { EmailAuthForm } from "./EmailAuthForm"
import { OAuthOptions } from "../OAuthOptions"

export const AuthProviders = () => {
return (
<VStack gap={20}>
<OAuthOptions />
<OptionsDivider />
<EmailAuthForm />
</VStack>
)
}

The EmailAuthForm component deploys react-hook-form to manage the form, although this can also be achieved without use of library, given the simplicity of having only an email address input. Upon submission, we employ the useMutation hook from react-query to forward email addresses to the GraphQL API. If you're interested in an efficient way to generate and utilize types for your GraphQL projects, feel free to check out another post.

import { analytics } from "analytics"
import { useForm } from "react-hook-form"
import { useMutation } from "react-query"
import { Button } from "@increaser/ui/ui/buttons/Button"
import { Form } from "@increaser/ui/ui/Form/Form"
import { TextInput } from "@increaser/ui/ui/inputs/TextInput"

import { useRouter } from "next/router"
import { Path } from "router/Path"
import { validateEmail } from "@increaser/utils/validation/validateEmail"
import { addQueryParams } from "@increaser/utils/addQueryParams"
import { useApi } from "api/useApi"
import { graphql } from "@increaser/api-interface/client"

interface EmailFormState {
email: string
}

const sendAuthLinkByEmailMutationDocument = graphql(`
mutation sendAuthLinkByEmail($input: SendAuthLinkByEmailInput!) {
sendAuthLinkByEmail(input: $input)
}
`)

export const EmailAuthForm = () => {
const { query } = useApi()

const { push } = useRouter()

const {
register,
handleSubmit,
formState: { errors },
} = useForm<EmailFormState>({
mode: "onSubmit",
})

const { mutate: sendAuthLinkByEmail, isLoading } = useMutation(
async ({ email }: EmailFormState) => {
analytics.trackEvent("Start identification with email")

await query(sendAuthLinkByEmailMutationDocument, {
input: {
email,
},
})

return email
},
{
onSuccess: (email) => {
push(addQueryParams(Path.EmailConfirm, { email }))
},
}
)

return (
<Form
gap={4}
onSubmit={handleSubmit((data) => {
sendAuthLinkByEmail(data)
})}
content={
<TextInput
label="Email address"
type="email"
autoFocus
placeholder="john@gmail.com"
{...register("email", {
required: "Please enter your email",
validate: validateEmail,
})}
error={errors.email?.message}
/>
}
actions={
<Button size="l" isLoading={isLoading}>
Continue
</Button>
}
/>
)
}

Front-End Implementation: Email Confirmation Page

Before sending the request, we also trigger an analytics event for performance monitoring of our email authentication funnel. Thus, at Increaser, we’ve determined that email authentication has almost same conversion rate as OAuth options, but more people choose to log in via Google compared to email.

Analytics funnel at Increaser

After a successful request, we’ll direct the user to the email confirmation page, which resides under the /email-confirm route at Increaser.

import { makeAuthPage } from "layout/makeAuthPage"
import { ConfirmEmailAuthView } from "@increaser/ui/auth/ConfirmEmailAuthView"

export default makeAuthPage(() => <ConfirmEmailAuthView sender="increaser" />)
Email confirmation page at Increaser

Within the ConfirmEmailAuthView component, we retrieve the email from the query parameters using the useHandleQueryParams hook from ReactKit. Here, we prompt the user to check their email inbox and provide a return button in case they wish to amend their email address or try other login options.

import { VStack } from "../ui/Stack"
import { Text } from "../ui/Text"
import { useState } from "react"
import { useRouter } from "next/router"
import { AuthView } from "./AuthView"
import { useHandleQueryParams } from "../navigation/hooks/useHandleQueryParams"
import { suggestInboxLink } from "@increaser/utils/suggestInboxLink"
import { ExternalLink } from "../navigation/Link/ExternalLink"
import { Button } from "../ui/buttons/Button"

interface EmailConfirmQueryParams {
email: string
}

interface ConfirmEmailAuthViewProps {
sender?: string
}

export const ConfirmEmailAuthView = ({ sender }: ConfirmEmailAuthViewProps) => {
const [email, setEmail] = useState<string | undefined>()
useHandleQueryParams<EmailConfirmQueryParams>(({ email }) => setEmail(email))

const { back } = useRouter()

const inboxLink = email && suggestInboxLink(email, sender)

return (
<AuthView title="Confirm your email">
{email && (
<>
<Text height="large" centered size={18}>
We emailed a magic link to <br />
<Text as="span" weight="bold">
{email}
</Text>
<br />
Click the link to log in or sign up.
</Text>

<VStack gap={4} fullWidth>
{inboxLink && (
<ExternalLink style={{ width: "100%" }} to={inboxLink}>
<Button size="xl" as="div">
Check your inbox
</Button>
</ExternalLink>
)}
<Button onClick={back} size="xl" kind="ghostSecondary">
Back
</Button>
</VStack>
</>
)}
</AuthView>
)
}

Front-end Interaction: Email Authentication Flow

We implement the suggestInboxLink function to infer the email client in use.

import { isOneOf } from "./array/isOneOf"
import { extractEmailProvider } from "./extractEmailProvider"
import { match } from "./match"

const emailProvidersWithClient = [
"gmail",
"outlook",
"hotmail",
"live",
"yahoo",
"protonmail",
"aol",
"zoho",
] as const

const outlookInboxLink = "https://outlook.live.com/mail/0/inbox"

export const suggestInboxLink = (
email: string,
sender?: string
): string | undefined => {
const emailProvider = extractEmailProvider(email)
if (!emailProvider) return undefined

const emailProviderWithClient = isOneOf(
emailProvidersWithClient,
emailProvider
)
if (!emailProviderWithClient) return undefined

return match(emailProviderWithClient, {
gmail: () => {
const url = "https://mail.google.com/mail/u/0/#search"
if (!sender) return url

const searchStr = encodeURIComponent(`from:@${sender}+in:anywhere`)
return [url, searchStr].join("/")
},
outlook: () => outlookInboxLink,
hotmail: () => outlookInboxLink,
live: () => outlookInboxLink,
yahoo: () => "https://mail.yahoo.com/d/folders/1",
protonmail: () => "https://mail.protonmail.com/u/0/inbox",
aol: () => "https://mail.aol.com/webmail-std/en-us/inbox",
zoho: () => "https://mail.zoho.com/zm/#mail/folder/inbox",
})
}

Initially, we extract the provider’s name from the email using a basic regex expression.

export const extractEmailProvider = (email: string): string | undefined => {
const regex = /(?<=@)\w+/

const match = email.match(regex)
return match ? match[0] : undefined
}

Then, we cross-reference the provider against a list of those that offer a web client, returning a link to the inbox if a match is found. For Gmail, we include a search query to highlight emails from the sender. Instead of a switch-case statement, we use the match function from ReactKit.

Back-End Implementation: Managing Email Authentication Process

At this point, the user should receive an email containing a link to the /email-auth page, complete with a special authentication code. We'll delve into email distribution shortly; for now, let's examine the final phase of authentication flow on the front-end.

import { useHandleQueryParams } from "navigation/hooks/useHandleQueryParams"
import { useCallback } from "react"
import { AuthView } from "./AuthView"
import { getCurrentTimezoneOffset } from "@increaser/utils/time/getCurrentTimezoneOffset"
import { AuthConfirmationStatus } from "./AuthConfirmationStatus"
import { QueryApiError } from "api/useApi"
import { useAuthenticateWithEmailMutation } from "auth/hooks/useAuthenticateWithEmailMutation"

interface EmailAuthParams {
code: string
}

export const EmailAuthContent = () => {
const { mutate: identify, error } = useAuthenticateWithEmailMutation()

useHandleQueryParams<EmailAuthParams>(
useCallback(
({ code }) => {
identify({
code,
timeZone: getCurrentTimezoneOffset(),
})
},
[identify]
)
)

return (
<AuthView title={`Continue with email`}>
<AuthConfirmationStatus error={error as QueryApiError | undefined} />
</AuthView>
)
}

The EmailAuthContent manages the query parameter through the same useHandleQueryParams hook. However, this time we'll call the useAuthenticateWithEmailMutation hook to dispatch the code to the GraphQL API. Here, the user is shown either a spinner (whilst the request is still being processed) or an error message (if something goes wrong). A successful request prompts an immediate redirection for the user to the home page. To display the status, we render the AuthConfirmationStatus component, which is also reused in the OAuth flow. We also provide a prompt to contact support in cases where the user encounters an error.

import { CopyText } from "@increaser/ui/ui/CopyText"
import { Spinner } from "@increaser/ui/ui/Spinner"
import { VStack } from "@increaser/ui/ui/Stack"
import { Text } from "@increaser/ui/ui/Text"
import { Button } from "@increaser/ui/ui/buttons/Button"
import { InfoIcon } from "@increaser/ui/ui/icons/InfoIcon"
import { QueryApiError } from "api/useApi"
import Link from "next/link"
import { Path } from "router/Path"
import { AUTHOR_EMAIL } from "shared/externalResources"

interface AuthConfirmationStatusProps {
error?: QueryApiError
}

export const AuthConfirmationStatus = ({
error,
}: AuthConfirmationStatusProps) => {
return (
<VStack alignItems="center" gap={20}>
<Text
style={{ display: "flex" }}
color={error ? "alert" : "regular"}
size={80}
>
{error ? <InfoIcon /> : <Spinner />}
</Text>
{error ? (
<>
<Text style={{ wordBreak: "break-word" }} centered height="large">
{error.message}
</Text>
<Link style={{ width: "100%" }} href={Path.SignIn}>
<Button kind="secondary" style={{ width: "100%" }} size="l">
Go back
</Button>
</Link>
<Text centered color="supporting" size={14}>
Nothing helps? Email us at <br />
<CopyText color="regular" as="span" content={AUTHOR_EMAIL}>
{AUTHOR_EMAIL}
</CopyText>
</Text>
</>
) : null}
</VStack>
)
}

Within the useAuthenticateWithEmailMutation hook, we use the useApi hook that abstracts server interactions to send a request to our GraphQL API. Upon successful request, we'll update the state with the JWT token that we will use to authenticate the user on the front-end.

import { graphql } from "@increaser/api-interface/client"
import { useMutation } from "react-query"

import { useApi } from "api/useApi"
import { AuthSessionWithEmailInput } from "@increaser/api-interface/client/graphql"
import { useAuthSession } from "./useAuthSession"

const authSessionWithEmailDocument = graphql(`
query authSessionWithEmail($input: AuthSessionWithEmailInput!) {
authSessionWithEmail(input: $input) {
token
expiresAt
isFirst
}
}
`)

export const useAuthenticateWithEmailMutation = () => {
const { query } = useApi()

const [, updateSession] = useAuthSession()

return useMutation(async (input: AuthSessionWithEmailInput) => {
const { authSessionWithEmail } = await query(authSessionWithEmailDocument, {
input,
})

updateSession(authSessionWithEmail)
})
}

Back-End Implementation: Generating and Sending Auth Link

We store the JWT token in the local storage, after which the useApi hook will attach the token to every request to the GraphQL API. The use of the useAuthSession wraps around the usePersistentState hook, which is expounded upon in my other post. This stage also involves sending analytics events to monitor authentication flow, as this hook is the sole process by which we modify session state.

import { AuthSession } from "@increaser/api-interface/client/graphql"
import { analytics } from "analytics"
import { useCallback } from "react"
import { useQueryClient } from "react-query"
import { PersistentStateKey, usePersistentState } from "state/persistentState"

export const useAuthSession = () => {
const queryClient = useQueryClient()

const [session, setSession] = usePersistentState<AuthSession | undefined>(
PersistentStateKey.AuthSession,
undefined
)

const onChange = useCallback(
(session: AuthSession | undefined) => {
if (session) {
analytics.trackEvent("Finish identification")

if (session.isFirst) {
analytics.trackEvent("Finish Sign Up")
}
} else {
queryClient.clear()
}

setSession(session)
},
[queryClient, setSession]
)

return [session, onChange] as const
}

An essential point of observation is that I disable React’s strict mode; this prevents the component from re-rendering during local usage of the app and causing a second API request. This is achieved by adding reactStrictMode: false to the next.config.js file. If you know of a method that prevents a second request while maintaining React's strict mode, please share it.

const nextConfig = {
reactStrictMode: false,
// ...
}

From our front-end interaction with the API, it’s clear that there must be two GraphQL endpoints managing the email authentication process. The sendAuthLinkByEmail mutation sends an email containing a unique code to the user, while the authSessionWithEmail query authenticates the user and returns a session - this includes a JWT token, expiry date, and a flag signifying if it's the user's first time logging in. In the AuthSessionWithEmailInput, we include a timeZone parameter. This is specific to Increaser, and may not be necessary for your application.

type AuthSession {
token: String!
expiresAt: Int!
isFirst: Boolean
}

input SendAuthLinkByEmailInput {
email: String!
}

input AuthSessionWithEmailInput {
code: String!
timeZone: Int!
}

type Query {
authSessionWithEmail(input: AuthSessionWithEmailInput!): AuthSession!
}

type Mutation {
sendAuthLinkByEmail(input: SendAuthLinkByEmailInput!): Boolean
}

Next, let’s explore the implementation of the sendAuthLinkByEmail mutation. We generate a JWT token for authentication, construct a login URL (which leads to the /email-auth page on our front-end with an attached auth code as a query parameter), and finally send the user an email comprising this login URL.

import { addQueryParams } from "@increaser/utils/addQueryParams"
import { MutationResolvers } from "../../gql/schema"
import { generateAuthLinkToken } from "../helpers/generateAuthLinkToken"
import { assertEnvVar } from "../../shared/assertEnvVar"
import { sendLoginLinkEmail } from "@increaser/email/utils/sendLogInLinkEmail"

export const sendAuthLinkByEmail: MutationResolvers["sendAuthLinkByEmail"] =
async (_: any, { input: { email } }): Promise<boolean> => {
const code = await generateAuthLinkToken(email)
const loginUrl = addQueryParams(`${assertEnvVar("APP_URL")}/email-auth`, {
code,
})

await sendLoginLinkEmail({
loginUrl,
email,
})

return true
}

Creating the code involves the jsonwebtoken library. The token is signed with a secret coming from the getSecret function, which varies based on your infrastructure setup. Maybe you can safely use environment variables, or if you operate within AWS - Secrets Manager (which I've discussed in this post) could be a good option. The code expires in 20 minutes. We employ the convertDuration function from ReactKit to translate this duration from minutes to seconds.

import jwt from "jsonwebtoken"
import { getTokenExpirationTime } from "./getTokenExpirationTime"
import { getSecret } from "../../utils/getSecret"
import { convertDuration } from "@increaser/utils/time/convertDuration"

export const generateAuthLinkToken = async (email: string) =>
jwt.sign(
{
email,
exp: getTokenExpirationTime(convertDuration(20, "min", "s")),
},
await getSecret("EMAIL_SECRET")
)

Managing Email Packages and Delivery

I keep my packages in a monorepo where the sendLoginLinkEmail function exists within the email package. Here, we use the react-email library to render the email templates and the sendEmail function to dispatch the emails. My first experience with react-email has been very positive. Prior to this, I was using a simple HTML string template that I would send to myself to verify that everything looked as it should. However, react-email allows email previewing in the browser and a more comfortable interaction with React as opposed to the novel syntax of other libraries like MJML.

import { render } from "@react-email/render"
import { sendEmail } from "./sendEmail"
import { productName } from "@increaser/entities"
import { getEnvVar } from "./getEnvVar"
import LoginLinkEmail, { LoginLinkEmailProps } from "../emails/LoginLinkEmail"

export const sendLoginLinkEmail = async ({
loginUrl,
email,
}: LoginLinkEmailProps) => {
const body = render(<LoginLinkEmail loginUrl={loginUrl} email={email} />, {
pretty: true,
})

return sendEmail({
email,
body,
subject: `Log in to ${productName}`,
source: `Log in <noreply@${getEnvVar("EMAIL_DOMAIN")}>`,
})
}

The LoginLinkEmail component generates a simple email, featuring an app logo, heading, text detailing the link's validity duration, a login button, and the recipient email address. Notably, we're able to import a theme from the ui package to color buttons and text. The react-email Font component is used to load the same font as we use on the front-end.

import { lightTheme } from "@increaser/ui/ui/theme/lightTheme"
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Img,
Preview,
Section,
Text,
Font,
} from "@react-email/components"

export interface LoginLinkEmailProps {
loginUrl: string
email: string
}

export const LoginLinkEmail = ({
loginUrl,
email = "john@gmail.com",
}: LoginLinkEmailProps) => (
<Html>
<Head>
<Font
fontFamily="OpenSans"
fallbackFontFamily="Verdana"
webFont={{
url: "https://fonts.gstatic.com/s/opensans/v36/memvYaGs126MiZpBA-UvWbX2vVnXBbObj2OVTS-mu0SC55I.woff2",
format: "woff2",
}}
fontWeight={400}
fontStyle="normal"
/>
<Font
fontFamily="OpenSans"
fallbackFontFamily="Verdana"
webFont={{
url: "https://fonts.gstatic.com/s/opensans/v36/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsg-1x4gaVIUwaEQbjA.woff2",
format: "woff2",
}}
fontWeight={700}
fontStyle="normal"
/>
</Head>
<Preview>Log in to Increaser</Preview>
<Body style={main}>
<Container style={container}>
<Img
src={`https://increaser.org/images/logo.png`}
width="68"
height="68"
alt="Increaser"
/>
<Heading style={heading}>Increaser</Heading>
<Text>
Click the button below to log in to <b>Increaser</b>.
<br />
This button will expire in 20 minutes.
</Text>
<Section style={buttonContainer}>
<Button pY={20} pX={20} style={button} href={loginUrl}>
Log in to Increaser
</Button>
</Section>
<Text>
Confirming this request will securely log you in using{" "}
<a style={userEmail}>{email}</a>.
</Text>
<Text>- Increaser Team</Text>
</Container>
</Body>
</Html>
)

export default LoginLinkEmail

const main = {
backgroundColor: lightTheme.colors.background.toCssValue(),
color: lightTheme.colors.contrast.toCssValue(),
fontFamily:
'OpenSans,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
}

const container = {
margin: "0 auto",
padding: "20px 0 48px",
width: "500px",
}

const heading = {
fontSize: "32px",
fontWeight: "700",
lineHeight: "1.3",
}

const button = {
backgroundColor: lightTheme.colors.primary.toCssValue(),
borderRadius: "8px",
fontWeight: "700",
color: lightTheme.colors.background.toCssValue(),
fontSize: "18px",
textDecoration: "none",
textAlign: "center" as const,
display: "block",
}

const buttonContainer = {
padding: "8px 0 8px",
}

const userEmail = {
textDecoration: "none",
color: lightTheme.colors.primary.toCssValue(),
fontWeight: 700,
}

The render function simply converts the React component to an HTML string. At Increaser I use AWS SES to send emails, a cost-effective solution that suits my volume. The problematic aspect is that it can't accommodate non-operational emails, as this can quickly lead to crossing the complaint threshold - I'm on the boundary of this while only sending transactional emails without any promotional content. In future projects, I plan to try another provider. The advantage of the monorepo approach is that it’s easy to replace the implementation of the sendEmail function in the email package while the other packages continue to function with the new implementation. However, updating environment variables in the packages that would utilize the new provider might be a bit cumbersome.

import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"
import { getEnvVar } from "./getEnvVar"

interface SendEmailParameters {
email: string
body: string
subject: string
source: string
}

const client = new SESv2Client({ region: getEnvVar("SES_AWS_REGION") })

export const sendEmail = ({
email,
body,
subject,
source,
}: SendEmailParameters) => {
const command = new SendEmailCommand({
Destination: {
ToAddresses: [email],
},
Content: {
Simple: {
Body: {
Html: {
Data: body,
},
},
Subject: {
Data: subject,
},
},
},
FromEmailAddress: source,
})

return client.send(command)
}

Last Step: User Authentication and Validation

In the sendEmail function, we generate a SendEmailCommand which is sent via the SESv2Client. I depend on the getEnvVar function to access environment variables; it houses a type variable name parameter and throws an error if the specified variable is undefined.

type VariableName = "SES_AWS_REGION" | "EMAIL_DOMAIN"

export const getEnvVar = (name: VariableName): string => {
const value = process.env[name]
if (!value) {
throw new Error(`Missing ${name} environment variable`)
}

return value
}

The last step is to authenticate with the authSessionWithEmail query. Initial validation is carried out before returning a session.

import { OperationContext } from "../../gql/OperationContext"
import { QueryResolvers } from "../../gql/schema"
import { authenticateWithEmail } from "../utils/authenticateWithEmail"
import { authorize } from "../utils/authorize"

export const authSessionWithEmail: QueryResolvers<OperationContext>["authSessionWithEmail"] =
async (_, { input: { code, timeZone } }, { country }) => {
const result = await authenticateWithEmail({
code,
})

return authorize({
timeZone,
country,
...result,
})
}

The authenticateWithEmail function verifies the JWT token and extracts the email from its payload.

import { getSecret } from "../../utils/getSecret"
import { AuthenticationResult } from "./AuthenticationResult"
import jwt from "jsonwebtoken"

interface AuthenticateWithEmailParams {
code: string
}

interface EmailCodePayload {
email: string
}

export const authenticateWithEmail = async ({
code,
}: AuthenticateWithEmailParams): Promise<AuthenticationResult> => {
const secret = await getSecret("EMAIL_SECRET")
const { email } = jwt.verify(code, secret) as EmailCodePayload

return {
email,
}
}

The authorize function checks if the user associated with the given email exists in the database, creating one if necessary.

import { getUserByEmail, putUser } from "@increaser/db/user"
import { AuthSession } from "../../gql/schema"
import { AuthenticationResult } from "./AuthenticationResult"
import { getAuthSession } from "./getAuthSession"
import { getUserInitialFields } from "@increaser/entities-utils/user/getUserInitialFields"

interface AuthorizeParams extends AuthenticationResult {
timeZone: number
country?: string
}

export const authorize = async ({
email,
name,
country,
timeZone,
}: AuthorizeParams): Promise<AuthSession> => {
const existingUser = await getUserByEmail(email, ["id"])
if (existingUser) {
return getAuthSession(existingUser.id)
}

const newUser = getUserInitialFields({
email,
name,
country,
timeZone,
})

await putUser(newUser)

return {
...getAuthSession(newUser.id),
isFirst: true,
}
}

It then returns a session with a JWT token, expiration date, and a flag denoting if the user is logging in for the first time using the getAuthSession function.

import { convertDuration } from "@increaser/utils/time/convertDuration"
import { AuthSession } from "../../gql/schema"
import jwt from "jsonwebtoken"
import { getSecret } from "../../utils/getSecret"

const tokenLifespanInDays = 300

export const getAuthSession = async (id: string): Promise<AuthSession> => {
const expiresAt = Math.round(
convertDuration(Date.now(), "ms", "s") +
convertDuration(tokenLifespanInDays, "d", "s")
)
const secret = await getSecret("SECRET")
const token = jwt.sign({ id, exp: expiresAt }, secret)

return {
token,
expiresAt,
}
}

--

--