Password-less authentication in NextJS application with WebAuthn and NextAuth
Tags: webauthn, nextjs, webdev, passwordless
Published: 2021-10-04 11:37:35

This tutorial explains step by step how to implement passwordless authentication with Email link and WebAuthn using NextAuth library in a NextJS application. The user will be able to sign in with Email link or WebAuthn. The latter login method will be available to him after the registration of his credential.

> *The Web Authentication API (also known as WebAuthn) is a specification written by the W3C and FIDO, with the participation of Google, Mozilla, Microsoft, Yubico, and others. The API allows servers to register and authenticate users using public key cryptography instead of a password.*
> 

*Source*: [https://webauthn.guide](https://webauthn.guide/)

I recommend [this guide](https://webauthn.guide) as a good introduction to WebAuthn standard.

## Why should we care about WebAuthn ?

Here are the main reasons for using WebAuthn:

- It is a user friendly authentication method that takes advantage of recent biometric user verification modules like Touch ID and Face ID.
- It is highly secure as no secret is sent over the wire during authentication.
- It is a [W3C specification](https://www.w3.org/TR/webauthn-2/) already implemented by [major browsers](https://caniuse.com/?search=webauthn).

### How WebAuthn works ?

WebAuthn has two workflows: registration and authentication. They involve three components:

- the website server called *the* *relying* *party*
- the client side of the website running on user’s browser called *the* *client*
- and the *authenticator*, a client side module responsible for user verification and cryptography tasks. It can be an external device connected to user’s machine, like a [yubico authenticator](https://www.yubico.com/products/yubico-authenticator/), or it can be built into user’s device like Touch ID, Face ID or Windows Hello.

Registration workflow works as following:

- the client fetches registration options from the server
- the authenticator performs user verification and generates a pair of private / public key bound to the website origin.
- the client sends the public key to the server which stores the key after verifications.

![webauthn_registration.png](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fkguapjketr23wb24nmg.png)

WebAuthn registration workflow

Authentication workflow works as following:

- the server sends a challenge to the client. A challenge is a random string generated server side.
- the authenticator verifies the user and signs the challenge with the private key generated during the registration step. The signed challenge is sent to the server.
- the server verifies the signed challenge with the user’s public key stored during the registration step.

![webauthn_authentication.png](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o7ko58f1uzm9s4sqomej.png)

WebAuthn authentication workflow

In the next sections, we will start building the demo application.

## Prerequisites

- A basic knowledge of [NextJS](https://nextjs.org/docs/getting-started).
- A basic knowledge of [NextAuth](https://next-auth.js.org/getting-started/example).
- A MongoDB instance.
- Access to an smtp server for email sending. You can use an smtp service provider like SendGrid or Mailgun.

Now let's get to the fun part.

## Project setup

First, create a NextJS application. I'm using [Typescript](https://www.typescriptlang.org), but it is not required. You can use Javascript with minor changes. I'm also using `yarn` to manage dependencies, but you can use `npm` if you prefer. I will not cover `npm` syntax though.

```bash
yarn create next-app --typescript
```
Choose a project name when you are asked to. From now on, commands must be run at the root of your project. So, type the following command in your terminal:

```bash
cd <path/to/your/project>
```

Then add NextAuth. We will use NextAuth v4 currently in beta. We also install `nodemailer` for Email link sign in. 

```bash
yarn add next-auth@beta nodemailer
```

To store users, we need a database. NextAuth can be used with many databases. In this guide, we will use MongoDB. Since we are using the `beta` version of NextAuth, we need to install the `next` tag of the MongoDB adapter for NextAuth.

```bash
yarn add @next-auth/mongodb-adapter@next mongodb
```

We will use [SimpleWebAuthn](https://simplewebauthn.dev) library for WebAuthn sign in and sign up implementation.

```bash
yarn add @simplewebauthn/browser @simplewebauthn/server base64url
```

To make it work with NextJS compilation, we need `next-transpile-modules`

```bash
yarn add -D next-transpile-modules
```

Now replace the content of `next.config.js` file with:

```jsx
/** @type {import('next').NextConfig} */
// next.config.js
const withTM = require('next-transpile-modules')(['@simplewebauthn/browser']); // pass the modules you would like to see transpiled

module.exports = withTM({
    reactStrictMode: true,
});
```

This is it for the project setup. In the next section we will implement Email link sign in.

## Email link sign in setup

To configure Email link sign in, you need an smtp provider. Create a `.env.local` file in the root of your project with the following content:

```bash
NODE_ENV="development"
EMAIL_SERVER="smtp://<smtpuser>:<smtppassword>@<smtpserver>:<smtpport>"

# example value: "accounts@mydomain.com"
EMAIL_FROM="<...>"

# example value: "mongodb://localhost/"
MONGODB_URI="<...>"

# can be watever you want
NEXT_AUTH_DBNAME="next-web-authn" 
```

Create a file at `lib/mongodb.ts` with the following content. This module configures and exports MongoDB client. Large part of this code is borrowed from [NextAuth documentation](https://next-auth.js.org/adapters/mongodb).

```tsx
// This approach is taken from https://github.com/vercel/next.js/tree/canary/examples/with-mongodb
import { MongoClient } from 'mongodb';

const uri = process.env.MONGODB_URI;
const options = {
    //useUnifiedTopology: true,
    //useNewUrlParser: true,
};

let client: MongoClient;
let clientPromise: Promise<MongoClient>;

if (!uri) {
    throw new Error('Please add your Mongo URI to .env.local');
}

if (process.env.NODE_ENV === 'development') {
    // In development mode, use a global variable so that the value
    // is preserved across module reloads caused by HMR (Hot Module Replacement).
    if (!(global as any)._mongoClientPromise) {
        client = new MongoClient(uri, options);
        (global as any)._mongoClientPromise = client.connect();
    }
    clientPromise = (global as any)._mongoClientPromise;
} else {
    // In production mode, it's best to not use a global variable.
    client = new MongoClient(uri, options);
    clientPromise = client.connect();
}

// Export a module-scoped MongoClient promise. By doing this in a
// separate module, the client can be shared across functions.
export default clientPromise;

export async function getDb(dbName: string) {
    const client = await clientPromise;
    return client.db(dbName);
}
```

Create a custom sign in page at `pages/auth/signin.tsx` with the following content.

```tsx
import { signIn, useSession } from 'next-auth/react';
import { ChangeEvent, KeyboardEventHandler, useEffect, useState } from 'react';
import { useRouter } from 'next/router';

import styles from '../../styles/Home.module.css'

export default function SignInComponent() {
    const [email, setEmail] = useState('');
    const [isValid, setIsValid] = useState(false);

    const router = useRouter();
    const { status } = useSession();

    useEffect(() => {
        if (status === 'authenticated') {
            router.push('/');
        }
    })

    async function signInWithEmail() {
        return signIn('email', { email })
    }

    async function handleSignIn() {
        await signInWithEmail();
    }

    const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (e) => {
        if (e.key === 'Enter') {
            return handleSignIn();
        }
    }

    function updateEmail(e: ChangeEvent<HTMLInputElement>) {
        setIsValid(e.target.validity.valid)
        setEmail(e.target.value);
    }

    return (
        <div className={styles.container}>
            <main className={styles.main}>
                <form onSubmit={e => e.preventDefault()}>
                    <input
                        name="email"
                        type="email"
                        id="email"
                        autoComplete="home email"
                        placeholder="Enter your email"
                        value={email}
                        onChange={updateEmail}
                        onKeyDown={handleKeyDown}
                    />
                    <button type="button" onClick={handleSignIn} disabled={!isValid}>
                        Sign in
                    </button>
                </form>
            </main>
        </div>
    )
}
```

Create a file at `pages/api/auth/[...nextauth].ts` with the following content. This file will be updated later with a credential provider for WebAuthn authentication.

```tsx
import { NextApiRequest, NextApiResponse } from 'next';
import NextAuth from 'next-auth';
import EmailProvider from 'next-auth/providers/email';
import { MongoDBAdapter } from '@next-auth/mongodb-adapter';
import { getDb } from '../../../lib/mongodb';

const nextAuthDbName = process.env.NEXT_AUTH_DBNAME!;

export default async function auth(req: NextApiRequest, res: NextApiResponse) {
    return NextAuth(req, res, {
        providers: [
            EmailProvider({
                server: process.env.EMAIL_SERVER,
                from: process.env.EMAIL_FROM
            }),
        ],
        adapter: MongoDBAdapter({
            db: await getDb(nextAuthDbName)
        }),
        session: {
            jwt: true,
        },
        pages: {
            signIn: '/auth/signin',
        }
    })
}
```

To configure NextAuth session state, update or create `pages/_app.tsx` with the bellow content:

```tsx
import '../styles/globals.css';
import type { AppProps } from 'next/app';
import { SessionProvider } from 'next-auth/react';

export default function App({ Component, pageProps: { session, ...pageProps } }: AppProps) {
    return (
        <SessionProvider session={session}>
            <Component {...pageProps} />
        </SessionProvider>
    )
}
```

Now replace the content of `pages/index.tsx` with the code bellow. We will implement `registerWebauthn` function in the next section.

```tsx
import styles from '../styles/Home.module.css'
import { signIn, signOut, useSession } from 'next-auth/react';
import { startRegistration } from '@simplewebauthn/browser';

export default function Home() {
    const { data: session, status } = useSession({
        required: true, onUnauthenticated() {
            return signIn();
        }
    });

    async function registerWebauthn() {
        // TODO
    }

    if (status === 'authenticated') {
        return (
            <div className={styles.container}>

                <main className={styles.main}>
                    <h1 className={styles.title}>
                        Welcome to <a href="https://webauthn.guide/" target="_blank"
                                      rel="noopener noreferrer">Webauthn</a> Demo
                    </h1>
                    <button onClick={registerWebauthn}>Register Webauthn</button>

                    <span>Signed in as {session?.user?.email}</span>
                    <button onClick={() => signOut()}>Log out</button>
                </main>
            </div>
        );
    }
    return <div className={styles.container}>Loading...</div>;
}
```

This completes Email link sign in. Start or restart your application with `yarn dev` and visit [http://localhost:3000](http://localhost:3000). You will be redirected to the login page.

Enter your email address and click on `Sign in` button. A link will be sent to your Email address. Clicking on the link will log you in.

In the next section, we will implement WebAuthn registration.

## WebAuthn registration setup

In this section, we implement WebAuthn registration workflow.
To sign in with WebAuthn, the user must register a WebAuthn credential after signing in with email link. The registration workflow involves both server side and client side processing. The gif below shows the end result for chrome on Mac with Touch ID.

![user_registration_webauthn.gif](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/tung767njgazlyg40ss4.gif)

Registration on Chrome on Mac with Touch ID

To store user's public key, we will use a dedicated database in the same MongoDB instance, as we are already using MongoDB for NextAuth. We will use [NextJS api routes](https://nextjs.org/docs/api-routes/introduction) to implement server side processing. 

Open `.env.local` file and add the following lines at the end:

```bash
# can be whatever you want
WEBAUTHN_DBNAME="webauthn-demo"
APP_DOMAIN=localhost 
APP_ORIGIN=http://localhost:3000
APP_NAME="Webauthn Demo App"
```

Let us create utilities functions to interact with the database. Create `lib/webauthn.ts` file with the following content. During registration a random string called challenge is sent to the client to avoid replay attack. During authentication, challenge is used to verify that user owns a private key. `saveChallenge` and `getChallenge` functions are used to save and retrieve challenges from the database.

```tsx
import { getDb } from './mongodb';
import { Binary } from 'mongodb';

const dbName = process.env.WEBAUTHN_DBNAME!;

export interface DbCredential {
    credentialID: string;
    userID: string;
    transports: AuthenticatorTransport[];
    credentialPublicKey: Binary | Buffer;
    counter: number;
}

export async function saveChallenge({ userID, challenge }: { challenge: string, userID: string }) {
    const db = await getDb(dbName);
    await db.collection('challenge').updateOne({
        userID,
    }, {
        $set: {
            value: challenge
        }
    }, {
        upsert: true
    });
}

export async function getChallenge(userID: string) {
    const db = await getDb(dbName);
    const challengeObj = await db.collection<{ userID: string; value: string; }>('challenge').findOneAndDelete({
        userID
    })
    return challengeObj.value;
}

/**
 * saveCredentials stores the user's public key in the database.
 * @param cred user's public key
 */
export async function saveCredentials(cred: { transports: AuthenticatorTransport[]; credentialID: string; counter: number; userID: string; key: Buffer }) {
    const db = await getDb(dbName);
    await db.collection<DbCredential>('credentials').insertOne({
        credentialID: cred.credentialID,
        transports: cred.transports,
        userID: cred.userID,
        credentialPublicKey: cred.key,
        counter: cred.counter,
    })
}
```

We handle the server part first. We need two api endpoints: one for getting the registration options, and the other for saving the user's public key. We use the same url path with `GET` method dedicated to getting registration options and `POST` for saving user's public key.

Create a file at `pages/api/auth/webauthn/register.ts` with the following content. The `excludeCredentials` property of registration options avoid duplicate registrations from the same authenticator. To learn more about WebAuthn registration options, please visit [https://webauthn.guide](https://webauthn.guide/).

```tsx
import { NextApiRequest, NextApiResponse } from 'next';
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
import { getSession } from 'next-auth/react';
import { getDb } from '../../../../lib/mongodb';
import { RegistrationCredentialJSON } from '@simplewebauthn/typescript-types';
import { DbCredential, getChallenge, saveChallenge, saveCredentials } from '../../../../lib/webauthn';

const domain = process.env.APP_DOMAIN!;
const origin = process.env.APP_ORIGIN!;
const appName = process.env.APP_NAME!;
const dbName = process.env.WEBAUTHN_DBNAME!;

/**
 * handles GET /api/auth/webauthn/register.
 *
 * This function generates and returns registration options.
 */
async function handlePreRegister(req: NextApiRequest, res: NextApiResponse) {
    const session = await getSession({ req });
    const email = session?.user?.email;
    if (!email) {
        return res.status(401).json({ message: 'Authentication is required' });
    }
    const db = await getDb(dbName);
    const credentials = await db.collection<DbCredential>('credentials').find({
        userID: email,
    }).toArray();

    const options = generateRegistrationOptions({
        rpID: domain,
        rpName: appName,
        userID: email,
        userName: email,
        attestationType: 'none',
        authenticatorSelection: {
            userVerification: 'preferred',
        },
    });
    options.excludeCredentials = credentials.map(c => ({
        id: c.credentialID,
        type: 'public-key',
        transports: c.transports
    }));

    try {
        await saveChallenge({ userID: email, challenge: options.challenge })
    } catch (err) {
        return res.status(500).json({ message: 'Could not set up challenge.' })
    }
    return res.status(200).json(options);
}

/**
 * handles POST /api/auth/webauthn/register.
 * 
 * This function verifies and stores user's public key.
 */
async function handleRegister(
    req: NextApiRequest,
    res: NextApiResponse
) {
    const session = await getSession({ req });
    const email = session?.user?.email;
    if (!email) {
        return res.status(401).json({ success: false, message: 'You are not connected.' });
    }
    const challenge = await getChallenge(email);
    if (!challenge) {
        return res.status(401).json({ success: false, message: 'Pre-registration is required.' });
    }
    const credential: RegistrationCredentialJSON = req.body;
    const { verified, registrationInfo: info } = await verifyRegistrationResponse({
        credential,
        expectedRPID: domain,
        expectedOrigin: origin,
        expectedChallenge: challenge.value,
    });
    if (!verified || !info) {
        return res.status(500).json({ success: false, message: 'Something went wrong' });
    }
    try {
        await saveCredentials({
            credentialID: credential.id,
            transports: credential.transports ?? ['internal'],
            userID: email,
            key: info.credentialPublicKey,
            counter: info.counter
        })
        return res.status(201).json({ success: true })
    } catch (err) {
        return res.status(500).json({ success: false, message: 'Could not register the credential.' })
    }
}

export default async function WebauthnRegister(
    req: NextApiRequest,
    res: NextApiResponse
) {
    if (req.method === 'GET') {
        return handlePreRegister(req, res);
    }
    if (req.method === 'POST') {
        return handleRegister(req, res);
    }
    return res.status(404).json({ message: 'The method is forbidden.' })
}
```

Now, we handle the client part. In `pages/index.ts` file, set the implementation of `registerWebauthn` as bellow:

```tsx
async function registerWebauthn() {
    const optionsResponse = await fetch('/api/auth/webauthn/register');
    if (optionsResponse.status !== 200) {
        alert('Could not get registration options from server');
        return;
    }
    const opt = await optionsResponse.json();

    try {
        const credential = await startRegistration(opt)

        const response = await fetch('/api/auth/webauthn/register', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            body: JSON.stringify(credential),
            credentials: 'include'
        });
        if (response.status != 201) {
            alert('Could not register webauthn credentials.');
        } else {
            alert('Your webauthn credentials have been registered.')
        }
    } catch (err) {
        alert(`Registration failed. ${(err as Error).message}`);
    }

}
```

This completes the registration process implementation. 

Restart your application with `yarn dev` . On a supported browser,  once signed in with email link, you can click on `Register Webauthn`  button to perform registration.

In the next section we tackle WebAuthn authentication implementation.

## WebAuthn authentication setup

Like registration, authentication workflow involves both the server and the client. This is what the end result will look like on Chrome on Mac with Touch ID.

![user_authentication_webauthn.gif](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/takfbdv1zyqp3zzmi377.gif)

Authentication on chrome on Mac with Touch ID

Like in the registration workflow, we implement the `GetAuthenticationOptions` api in a NextJS api route. But, the verification part need to be done inside a [NextAuth credential provider](https://next-auth.js.org/configuration/providers/credentials-provider) `authorize`  function.

First, create a file at `pages/api/auth/webauthn/authenticate.ts` with the following content.

```tsx
import { NextApiRequest, NextApiResponse } from 'next';
import { generateAuthenticationOptions } from '@simplewebauthn/server'
import { getDb } from '../../../../lib/mongodb';
import { DbCredential, saveChallenge } from '../../../../lib/webauthn';

const dbName = process.env.WEBAUTHN_DBNAME!;

/**
 * handles GET /api/auth/webauthn/authenticate.
 * 
 * It generates and returns authentication options.
 */
export default async function WebauthnAuthenticate(
    req: NextApiRequest,
    res: NextApiResponse,
) {
    if (req.method === 'GET') {
        const email = req.query['email'] as string;
        if (!email) {
            return res.status(400).json({ message: 'Email is required.' });
        }
        const db = await getDb(dbName);
        const credentials = await db.collection<DbCredential>('credentials').find({
            userID: email,
        }).toArray();
        const options = generateAuthenticationOptions({
            userVerification: 'preferred',

        });

        options.allowCredentials = credentials.map(c => ({
            id: c.credentialID,
            type: 'public-key',
            transports: c.transports
        }));
        try {
            await saveChallenge({ userID: email, challenge: options.challenge })
        } catch (err) {
            return res.status(500).json({ message: 'Could not set up challenge.' })
        }
        return res.status(200).json(options);
    }
    return res.status(404).json({ message: 'The method is forbidden.' });
}
```

Now add a credential provider to NextAuth config in `pages/api/auth/[...nextauth].ts` file. Add the necessary imports first. Do not override existing imports.

```tsx
import CredentialsProvider from 'next-auth/providers/credentials';
import { verifyAuthenticationResponse } from '@simplewebauthn/server';
import base64url from 'base64url';
import { Document } from 'mongodb';
import { DbCredential, getChallenge } from '../../../lib/webauthn';
```

Then bellow the imports, add these variables:

```tsx
const domain = process.env.APP_DOMAIN!;
const origin = process.env.APP_ORIGIN!;
const webauthnDbName = process.env.WEBAUTHN_DBNAME!;
```

Finally, add this bellow Email provider in the  `providers` array:

```tsx
CredentialsProvider({
    name: 'webauthn',
    credentials: {},
    async authorize(cred, req) {
        const {
            id,
            rawId,
            type,
            clientDataJSON,
            authenticatorData,
            signature,
            userHandle,
        } = req.body;

        const credential = {
            id,
            rawId,
            type,
            response: {
                clientDataJSON,
                authenticatorData,
                signature,
                userHandle,
            },

        };
        const db = await getDb(webauthnDbName);
        const authenticator = await db.collection<DbCredential & Document>('credentials').findOne({
            credentialID: credential.id
        });
        if (!authenticator) {
            return null;
        }
        const challenge = await getChallenge(authenticator.userID);
        if (!challenge) {
            return null;
        }
        try {
            const { verified, authenticationInfo: info } = verifyAuthenticationResponse({
                credential: credential as any,
                expectedChallenge: challenge.value,
                expectedOrigin: origin,
                expectedRPID: domain,
                authenticator: {
                    credentialPublicKey: authenticator.credentialPublicKey.buffer as Buffer,
                    credentialID: base64url.toBuffer(authenticator.credentialID),
                    counter: authenticator.counter,
                },
            });

            if (!verified || !info) {
                return null;
            }
            await db.collection<DbCredential>('credentials').updateOne({
                _id: authenticator._id
            }, {
                $set: {
                    counter: info.newCounter
                }
            })
        } catch (err) {
            console.log(err);
            return null;
        }
        return { email: authenticator.userID };
    }
})
```

This is it for the server part. Now we tackle the client side implementation.

For privacy reasons, it is not possible to check if a user has already registered a WebAuthn credential with our website. Therefore, when user click on `sign in` button after filling up the text field with his email address, we try to perform WebAuthn authentication first, and if it fails, we fallback to email link sign in workflow. 

In the custom sign in page at `pages/auth/signin.tsx`, add these imports at the end of existing ones:

```tsx
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/typescript-types';
import { startAuthentication } from '@simplewebauthn/browser';
```

Then add a `signInWithWebauthn` function as bellow, just under `signInWithEmail` function:

```tsx
async function signInWithWebauthn() {
  const url = new URL(
      '/api/auth/webauthn/authenticate',
      window.location.origin,
  );
  url.search = new URLSearchParams({ email }).toString();
  const optionsResponse = await fetch(url.toString());

  if (optionsResponse.status !== 200) {
      throw new Error('Could not get authentication options from server');
  }
  const opt: PublicKeyCredentialRequestOptionsJSON = await optionsResponse.json();

  if (!opt.allowCredentials || opt.allowCredentials.length === 0) {
      throw new Error('There is no registered credential.')
  }

  const credential = await startAuthentication(opt);

  await signIn('credentials', {
      id: credential.id,
      rawId: credential.rawId,
      type: credential.type,
      clientDataJSON: credential.response.clientDataJSON,
      authenticatorData: credential.response.authenticatorData,
      signature: credential.response.signature,
      userHandle: credential.response.userHandle,
  })

}
```

Then, in the same file, update `handleSignIn` function body to look like this:

```tsx
async function handleSignIn() {
    try {
        await signInWithWebauthn();
    } catch (error) {
        console.log(error);
        await signInWithEmail();
    }
}
```

This completes the authentication implementation. Restart the application with `yarn dev` and log out if you are signed in. Enter your email address and click on sign in. If you have previously registered a WebAuthn credential, it will trigger WebAuthn authentication. Depending on your device, it may ask you to scan your fingerprint or enter a pin code. After a successful verification, you will be signed in.

So far, traditional two-factor authentications have been using a two-step workflow involving a password in general. This is not so convenient for users and most websites only use a single factor authentication to preserve user experience. 

Fortunately, with WebAuthn, we no longer need to choose between security and usability. We can now perform two-factor authentication in a single step. Not only is it more secure, but it also delivers a great user experience. 
This is a game changer.

The complete source code can be found on [GitHub](https://github.com/novopattern/next-webauthn-demo).

Thanks for reading.Your feedbacks are very welcome.