DEV Community

Jonatan Sombié
Jonatan Sombié

Posted on • Edited on

Password-less authentication in NextJS application with WebAuthn and NextAuth

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

I recommend this 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 already implemented by major browsers.

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, 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

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

WebAuthn authentication workflow

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

Prerequisites

  • A basic knowledge of NextJS.
  • A basic knowledge of NextAuth.
  • 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, 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.

yarn create next-app --typescript
Enter fullscreen mode Exit fullscreen mode

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:

cd <path/to/your/project>
Enter fullscreen mode Exit fullscreen mode

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

yarn add next-auth@beta nodemailer
Enter fullscreen mode Exit fullscreen mode

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.

yarn add @next-auth/mongodb-adapter@next mongodb
Enter fullscreen mode Exit fullscreen mode

We will use SimpleWebAuthn library for WebAuthn sign in and sign up implementation.

yarn add @simplewebauthn/browser @simplewebauthn/server base64url
Enter fullscreen mode Exit fullscreen mode

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

yarn add -D next-transpile-modules
Enter fullscreen mode Exit fullscreen mode

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

/** @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,
});
Enter fullscreen mode Exit fullscreen mode

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:

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" 
Enter fullscreen mode Exit fullscreen mode

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.

// 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);
}
Enter fullscreen mode Exit fullscreen mode

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

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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.

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',
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

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

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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

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>;
}
Enter fullscreen mode Exit fullscreen mode

This completes Email link sign in. Start or restart your application with yarn dev and visit 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

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 to implement server side processing.

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

# can be whatever you want
WEBAUTHN_DBNAME="webauthn-demo"
APP_DOMAIN=localhost 
APP_ORIGIN=http://localhost:3000
APP_NAME="Webauthn Demo App"
Enter fullscreen mode Exit fullscreen mode

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.

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,
    })
}
Enter fullscreen mode Exit fullscreen mode

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.

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.' })
}
Enter fullscreen mode Exit fullscreen mode

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

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}`);
    }

}
Enter fullscreen mode Exit fullscreen mode

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

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 authorize function.

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

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.' });
}
Enter fullscreen mode Exit fullscreen mode

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.

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';
Enter fullscreen mode Exit fullscreen mode

Then bellow the imports, add these variables:

const domain = process.env.APP_DOMAIN!;
const origin = process.env.APP_ORIGIN!;
const webauthnDbName = process.env.WEBAUTHN_DBNAME!;
Enter fullscreen mode Exit fullscreen mode

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

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 };
    }
})
Enter fullscreen mode Exit fullscreen mode

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:

import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/typescript-types';
import { startAuthentication } from '@simplewebauthn/browser';
Enter fullscreen mode Exit fullscreen mode

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

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,
  })

}
Enter fullscreen mode Exit fullscreen mode

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

async function handleSignIn() {
    try {
        await signInWithWebauthn();
    } catch (error) {
        console.log(error);
        await signInWithEmail();
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Thanks for reading.Your feedbacks are very welcome.

Top comments (2)

Collapse
 
allthecode profile image
Simon Barker

Great post, thanks. Two minor updates I had to make to get this to work in the [...nextauth].ts file

 adapter: MongoDBAdapter(clientPromise),
 session: {
   strategy: "jwt",
 },
Enter fullscreen mode Exit fullscreen mode

other than that, all still valid

Collapse
 
kopeboy profile image
Lorenzo Giovenali

What can you do later with the private key? Can you ask the device to generate it in some specific format?

If a user's device has existing keys, can he chose a specific one to provide?