Implementing Apple Passkeys using Next.JS and MongoDB

Vachan MN
17 min readJun 17, 2023

--

Apple Passkeys

In WWDC 2022, we saw Apple showcase “Passkeys”, a radically new kind of authentication flow that would change password-less authentication forever. This new protocol is called webauthn. In this post, I am going to implement this new technology in NextJS and mongoose (mongoDB).

Starting the project:

To begin, lets start by making a new nextJS project, run npx create-next-app and answer the questions. I will be referring to the project as learningwebauthn , feel free to give it any name you like. I will not be using typescript in this project. If you plan on using typescript, you will need to make the necessary changes on your own. I am making use of TailwindCSS for styles.

npx create-next-app
Example output from `create-next-app`

Installing Dependencies:

Before moving on to installing dependencies, remember to change the directory to the project.

cd learningwebauthn

We will now install the dependencies for the project, as mentioned before, we will be using mongoose for the database. We will also use iron-session for the session management since NextJS doesn’t support sessions out of the box. We will also use @github/webauthn-json for implementing webauthn. Run the following command to download and install the these packages.

npm install mongoose iron-session @github/webauthn-json
Example output of package installation

We also need one more package — @simplewebauthn/serverwhich we will use for challenge verification.

npm install @simplewebauthn/server
Example output from installing the package

Setting up the Database:

Lets get started with setting up mongoose.

Create a new folder called lib in the project’s root directory.

mkdir lib

Create a file lib/dbConnect.js . This file will contain all the code necessary to connect to MongoDB using mongoose.

import mongoose from 'mongoose'

const MONGODB_URI = process.env.MONGODB_URI

if (!MONGODB_URI) {
throw new Error(
'Please define the MONGODB_URI environment variable inside .env.local'
)
}

let cached = global.mongoose

if (!cached) {
cached = global.mongoose = { conn: null, promise: null }
}

async function dbConnect() {
if (cached.conn) {
return cached.conn
}

if (!cached.promise) {
const opts = {
bufferCommands: false,
}

cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
return mongoose
})
}

try {
cached.conn = await cached.promise
} catch (e) {
cached.promise = null
throw e
}

return cached.conn
}

export default dbConnect

In the above code, we import mongoose, then we check for the MongoDB URI which needs to be stored as an environment variable MONGODB_URI . We then define a new object called cached, which tries to get the mongoose instance from the global variables. In the next line, if the instance is not present in global, we create an object which says both the connection and promise are null. Now we define a function which returns the database connection. If the connection was present in cache, we simply return the same. If the promise is not defined, then we run mongoose.connect(...) to establish a connection with the database using the URI, after we get the connection, we save it in the cache. After this, we try to await the promise to get the connection. If an error occurs we throw it. After this, the connection is available at cached.conn , we return this. The last line simply exports the function by default.

Defining the data models:

Let us now define the models for storing the user and user information.

Create a new folder named models for storing the models.

mkdir models

Inside the folder, create a new file models/User.js . This file will have the model for the user. Add the following code for the user info, you can store any other user information depending upon your project.

import mongoose from "mongoose";

const UserSchema = new mongoose.Schema({
email: {
type: String,
unique: true,
},
username: {
type: String,
unique: true,
},
credentials: [
{
type: mongoose.Schema.Types.ObjectId,
ref: "Credentials",
},
],
});

export default mongoose.models.User || mongoose.model("User", UserSchema);

Inside the same folder, create a new file models/Credential.js . This file will have the model for the credentials linked to an user.

import mongoose from "mongoose";

const CredentialsSchema = new mongoose.Schema({
name: {
type: String,
},
externalId: {
type: String,
unique: true,
},
publicKey: {
type: String,
},
dateAdded: {
type: Date,
default: Date.now(),
},
});

export default mongoose.models.Credentials ||
mongoose.model("Credentials", CredentialsSchema);

Sessions setup:

Next, we setup the session cookie settings

Create a file lib/session.js inside the lib folder. Inside the file add the following code:

import { withIronSessionApiRoute, withIronSessionSsr } from "iron-session/next";

export const sessionOptions = {
cookieName: "webauthn-token",
password: process.env.COOKIE_SECRET,
cookieOptions: {
secure: process.env.NODE_ENV === "production",
},
};

export function withSessionAPI(handler) {
return withIronSessionApiRoute(handler, sessionOptions);
}

export function withSession(handler) {
return withIronSessionSsr(handler, sessionOptions);
}

The code exports an object with the options for sessions, it has a password that is stored in an environment variable called COOKIE_SECRET , this allows cookies to be stored securely. The cookieName is used to identify the cookie on the browser. The options are set so that the cookie is encrypted in a production environment. We also export 2 functions which we use later to get the session.

Registrations page:

Let us now start writing the actual user facing page, in the registrations page, we will accept the user’s information and setup the passkey.

Create a file pages/register.js and add the following code:

import { useEffect, useState } from "react";
import { supported } from "@github/webauthn-json";

export default function Register() {
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [support, setSupport] = useState(false);

useEffect(() => {
const checkAvailability = async () => {
const available =
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
setSupport(available && supported());
};
checkAvailability();
}, []);

const handleRegister = () => {};

return (
<div className="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
<h1 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight">
Register
</h1>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
{support ? (
<form method="POST" action="/api/register" onSubmit={handleRegister}>
<div className="p-3">
<label
htmlFor="email"
className="block text-sm font-medium leading-6"
>
Email
</label>
<input
type="email"
id="email"
name="email"
className="block w-full rounded-md border-0 py-1.5 text-black"
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="p-3">
<label
htmlFor="username"
className="block text-sm font-medium leading-6"
>
Username
</label>
<input
type="text"
id="username"
name="username"
className="block w-full rounded-md border-0 py-1.5 text-black"
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<button
type="submit"
className="w-full bg-blue-600 p-3 rounded-md py-1.5 block"
>
Register
</button>
</div>
</form>
) : (
<div>Sorry, your browser does not support WebAuthn.</div>
)}
</div>
</div>
);
}

The code above is a React element, it defines the registration page with 2 fields — email and username. The form only renders if webauthn is supported by the user’s browser. The form will submit this to the api which will return relevant information in order to continue the process. The onSubmit event of the form is linked to a function which we will write later. Here, we will need to handle the user info. By this point, the project should be runnable.

npm run dev

Run the above command and using your browser head over to http://localhost:3000/register . Here you should see the page we have designed.

Register Email Username Register
The registration page

Generating the challenge:

According to the webauthn protocol, we need to generate a challenge in order to implement webauthn.

Create a new file lib/auth.js and add the following code inside:

import crypto from "node:crypto";

function clean(str) {
return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}

export function generateChallenge() {
return clean(crypto.randomBytes(32).toString("base64"));
}

The code contains 2 functions, the clean function is used to remove the symbols that can break the auth process, the 2nd function generateChallenge generates the actual challenge and returns the cleaned version of the challenge for the auth process.

Running the challenge:

We now go back to pages/register.js and add the getServerSideProps function along with the session code.

import { useEffect, useState } from "react";
import { supported } from "@github/webauthn-json";

import { generateChallenge } from "@/lib/auth";

export default function Register({ challenge }) {
// Same as before
}


export const getServerSideProps = withSession(async function ({ req, res }) {
const challenge = generateChallenge();
req.session.challenge = challenge;
await req.session.save();
return { props: { challenge } };
});

The above code, allows the page to be rendered on the server-side, we generate the challenge and make it available for our element. We also save it in session using iron-session .

import { useEffect, useState } from "react";
import { create, supported } from "@github/webauthn-json";

import { generateChallenge } from "@/lib/auth";
import { withSession } from "@/lib/session";
import { useRouter } from "next/router";

export default function Register({ challenge }) {
const router = useRouter();

const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [support, setSupport] = useState(false);

const [error, setError] = useState("");

useEffect(() => {
const checkAvailability = async () => {
const available =
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
setSupport(available && supported());
};
checkAvailability();
}, []);

const handleRegister = async (event) => {
event.preventDefault();

const userAvailable = await fetch("/api/usercheck", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, username }),
});

if (userAvailable.status !== 200) {
const { message } = await userAvailable.json();
setError(message);
return;
}

const cred = create({
challenge: challenge,
rp: {
// These are seen by the authenticator when selecting which key to use
name: "WebAuthn Demo",
id: router.hostname,
},
user: {
// You can choose any id you please, as long as it is unique
id: window.crypto.getRandomValues(new Uint8Array(16)),
email: email,
username: username,
},
pubKeyCredParams: [{ alg: -7, type: "public-key" }],
timeout: 60000,
attestation: "direct",
authenticatorSelection: {
residentKey: "required",
userVerification: "required",
},
});

const res = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, username, cred }),
});

if (res.status === 200) {
router.push("/protected/home");
} else {
const { message } = await res.json();
setError(message);
}
};

return (
<div className="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
<h1 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight">
Register
</h1>
<pre className="text-center text-red-500">{error}</pre>
.
.
.
</div>
)

export const getServerSideProps = withSession(async function ({
.
.
.
});

We have made many changes in the code this time. First we import and get the router and define a new state variable to store the error. Inside the handleRegister function, first we make a call to the api to check wether the username or email is already registered. If it is, then we set the error and end the execution. Now we use the create function from the @github/webauthn-json to create a credential. This function uses the provided information and prompts the user to use their authentication medium to create the credential which is saved on the client. The credential is then sent along with the email and username to the API for registration. Now, we need to define the API routes to do these operations.

Defining the API routes:

Let’s start with the register api route.

Create a file pages/api/usercheck.js and add the following code:

import dbConnect from "@/lib/dbConnect";
import user from "@/models/User";

export default async function handler(req, res) {
await dbConnect();
const { email, username } = req.body;

const userExists = await user.findOne({
$or: [{ email: email }, { username: username }],
});

if (userExists) {
res.status(400).json({ message: "User already exists" });
} else {
res.status(200).json({ message: "User available" });
}
}

The above code checks if there is already an user who has the username or email already registered. (Here I am cutting a corner, you would want to check twice, once with email and once with username and return the message which tells the user wether it is the email that is registered or the email.)

Create a file pages/api/register.js and add the following code:

import { withSessionAPI } from "@/lib/session";

import user from "@/models/User";
import credentials from "@/models/Credentials";
import { verifyCredentials } from "@/lib/auth";
import dbConnect from "@/lib/dbConnect";

async function handler(request, response) {
try {
await dbConnect();
const { credentialID, publicKey } = await verifyCredentials(request);
const cred = await credentials.create({
name: request.body.username,
externalId: credentialID,
publicKey: publicKey,
});
const usr = await user.create({
email: request.body.email,
username: request.body.username,
credentials: [cred.id],
});
request.session.userId = user._id;
await request.session.save();
response.status(200).json({ userId: user._id });
} catch (error) {
response.status(500).json({ message: error.message });
}
}

export default withSessionAPI(handler);

This code verifies the credentials in the request and creates the user and credential in the database. It also sets the user’s ID in the session.

Edit the file lib/auth.js to be the following code

import crypto from "node:crypto";
import { verifyRegistrationResponse } from "@simplewebauthn/server";

const HOST_SETTINGS = {
expectedOrigin: process.env.VERCEL_URL ?? "http://localhost:3000",
expectedRPID: process.env.RPID ?? "localhost",
};

function clean(str) {
return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
}

export function generateChallenge() {
return clean(crypto.randomBytes(32).toString("base64"));
}

// Helper function to translate values between
// `@github/webauthn-json` and `@simplewebauthn/server`
function binaryToBase64url(bytes) {
let str = "";

bytes.forEach((charCode) => {
str += String.fromCharCode(charCode);
});

return btoa(str);
}

export async function verifyCredentials(request) {

const challenge = request.session.challenge ?? "";
const credential = request.body.cred ?? "";

if (credential == null) {
throw new Error("Invalid Credentials");
}

let verification;

verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: challenge,
requireUserVerification: true,
...HOST_SETTINGS,
});

if (!verification.verified) {
throw new Error("Invalid Credentials - Registration verification failed.");
}

const { credentialID, credentialPublicKey } =
verification.registrationInfo ?? {};

if (credentialID == null || credentialPublicKey == null) {
throw new Error("Registration failed");
}

return {
credentialID: clean(binaryToBase64url(credentialID)),
publicKey: Buffer.from(credentialPublicKey).toString("base64"),
};
}

The code above has 3 functions, the first one is used to make a string safe to work with, the second one converts the Binary data into a string which we can store, the 3rd function is the most important one, we get the challenge and credentials from the request and start the verification process, if it is not verified successfully we throw an error. We then get the credentialID and the public key from the verification progress and return it. There is also a constant object HOST_SETTINGS which contains information about where the project is hosted. We will use this constant later too.

Running the code so far:

You now need to get your MongoDB URI and add it in a .env.local file with the name MONGODB_URI . Add a random string of a minimum of 32 characters with the name COOKIE_SECRET . You should now be able to test this code. Run the code as before and open http://localhost:3000/register . Enter your email and a new username, you should be able to add the webauthn credential (passkey).

Checking if a user is logged in:

In the lib/auth.js file we add the following code:

.
.
.
import { GetServerSidePropsContext, NextApiRequest } from "next";

.
.
.

export function isLoggedIn(request) {
return request.session.userId != null;
}

Redirect logged in users from register page:

In the registration page, we can add the following code so already logged in users are automatically redirected.

In pages/register.js make the following changes:

import { useEffect, useState } from "react";
import { create, supported } from "@github/webauthn-json";

import { generateChallenge, isLoggedIn } from "@/lib/auth";
import { withSession } from "@/lib/session";
import { useRouter } from "next/router";

.
.
.

export const getServerSideProps = withSession(async function ({ req, res }) {
if (isLoggedIn(req)) {
return {
redirect: {
destination: "/protected/home",
permanent: false,
},
};
}
const challenge = generateChallenge();
req.session.challenge = challenge;
await req.session.save();
return { props: { challenge } };
});

In the above code, we are simply checking if the user is already logged in and redirecting them to the protected page. You can redirect the user to any page you like. You might also want to use something like the next query parameter from the URL.

The protected Page:

Let us now add the page in which we need to verify the authentication

Create a file protected/home.js and add the following code:

import { isLoggedIn } from "@/lib/auth";
import dbConnect from "@/lib/dbConnect";
import { withSession } from "@/lib/session";
import User from "@/models/User";

export default function ProtectedHome({ userID, user }) {
return (
<div className="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
<h1 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight">
Protected Home
</h1>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<p>
This page is protected and can only be accessed by authenticated
users.
</p>
<br />
<div>
You are logged in as:{" "}
<span className="font-mono">{user.username}</span>
<br /> with the email: <span className="font-mono">{user.email}</span>
</div>
<br />
<form method="POST" action="/api/logout">
<button className=" p-3 rounded-md py-1.5 block bg-blue-600">
Logout
</button>
</form>
</div>
</div>
);
}

export const getServerSideProps = withSession(async function ({ req, res }) {
if (!isLoggedIn(req)) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}

await dbConnect();
const user = await User.findOne({ _id: req.session.userId });

return {
props: {
userID: req.session.userId,
user: JSON.parse(JSON.stringify(user)),
},
};
});

The code above is simply a protected page which shows the username and email of the person logged in. In the getServerSideProps function, we use the isLoggedIn function to check the authentication. If the user is not logged in they are redirected to the login page (yet to be created). We also get the user’s information from the database.

The protected page

Login Page:

Let us now create a Login page so the users can login.

Create a file called pages/login.js and add the following code:

import { generateChallenge, isLoggedIn } from "@/lib/auth";
import { withSession } from "@/lib/session";
import { supported, create, get } from "@github/webauthn-json";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";

export default function Login({ challenge }) {
const router = useRouter();
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const [support, setSupport] = useState(false);

useEffect(() => {
const checkAvailability = async () => {
const available =
await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
setSupport(available && supported());
};

checkAvailability();
}, []);

const handleLogin = async (event) => {
event.preventDefault();

const credential = await get({
publicKey: {
challenge,
timeout: 60000,
userVerification: "required",
rpId: "localhost",
},
});

const result = await fetch("/api/login", {
method: "POST",
body: JSON.stringify({ email, credential }),
headers: {
"Content-Type": "application/json",
},
});

if (result.status !== 200) {
try {
const { message } = await result.json();
setError(message);
} catch (e) {
setError("Something went wrong");
}
return;
} else {
router.push("/protected/home");
}
};

return (
<div className="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
<h1 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight">
Login
</h1>

{support ? (
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
{error && <div className="text-red-500">{error}</div>}
<form method="POST" onSubmit={handleLogin}>
<div className="p-3">
<label
htmlFor="email"
className="block text-sm font-medium leading-6"
>
email
</label>
<input
type="email"
id="email"
name="email"
className="block w-full rounded-md border-0 py-1.5 text-black"
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="p-3">
<button
type="submit"
className="w-full bg-blue-600 p-3 rounded-md py-1.5 block"
>
Login
</button>
</div>
</form>
</div>
) : (
<div>Webauthn is not supported by your browser.</div>
)}
</div>
);
}

export const getServerSideProps = withSession(async function ({ req, res }) {
if (isLoggedIn(req)) {
return {
redirect: {
destination: "/protected/home",
permanent: false,
},
};
}
const challenge = await generateChallenge();
req.session.challenge = challenge;
await req.session.save();

return { props: { challenge } };
});

This code looks very similar to the register page, but the difference is that we are now using the get() function to get the webauthn credentials. Once we get the credentials we post it to the API which verifies the credentials and email ID. If it is verified, we redirect the user to a protected page, else we display the error.

The login page

Create a file pages/api/login.js and add the following code:

import { withSessionAPI } from "@/lib/session";
import { login } from "@/lib/auth";

async function handler(request, response) {
try {
const userId = await login(request);
request.session.userId = userId;
await request.session.save();

response.status(200).json(userId);
} catch (error) {
response.status(500).json({ message: error.message });
}
}

export default withSessionAPI(handler);

In the above code, we are call the login function which we will now define in the auth file. After the userID is received from the login function, we save it to the user’s session.

In the lib/auth.js file add the following functions:

.
.
.

function base64ToArray(base64) {
var binaryString = atob(base64);
var bytes = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}

export async function login(request) {
await dbConnect();
const challenge = request.session.challenge ?? "";
const credential = request.body.credential ?? "";
const email = request.body.email ?? "";

if (credential?.id == null) {
throw new Error("Invalid Credentials");
}

const cred = await Credentials.findOne({ externalId: credential.id });
if (cred == null) {
throw new Error("Invalid Credentials");
}

let verification;

try {
verification = await verifyAuthenticationResponse({
response: credential,
expectedChallenge: challenge,
requireUserVerification: true,
authenticator: {
credentialID: cred.externalId,
credentialPublicKey: base64ToArray(cred.publicKey),
},
...HOST_SETTINGS,
});
} catch (error) {
console.error(error);
throw error;
}

const usr = await User.findOne({ credentials: cred._id });

if (!verification.verified || email !== usr.email) {
throw new Error("Login verification Failed");
}

return usr._id;
}

The first function is used to convert the externalID we saved to bytes. (We previously converted it to a string so we can save it to MongoDB). The 2nd function is the login function. Here we find the credential in the database and verify that the client does indeed have the right credentials. Once we check that, we search the database for the user which is linked to the credential. Using the user info we verify that the user has entered the correct email for the credential. After that we return the user’s ID for saving in the session.

Logout:

Now that we have a way to register and login, we need to implement a way to logout, let us add that now.

In the protected page: pages/protected/home.js edit the code to look like this:

import { isLoggedIn } from "@/lib/auth";
import { withSession } from "@/lib/session";

export default function ProtectedHome({ userID }) {
return (
<div>
<h1>Protected Home</h1>
<p>
This page is protected and can only be accessed by authenticated users.
</p>
<p>You are logged in as: {userID} </p>
<form
method="POST"
action="/api/logout"
className=" p-3 rounded-md py-1.5 block"
>
<button>Logout</button>
</form>
</div>
);
}

export const getServerSideProps = withSession(async function ({ req, res }) {
if (!isLoggedIn(req)) {
return {
redirect: {
destination: "/login",
permanent: false,
},
};
}
return {
props: {
userID: req.session.userId,
},
};
});

The code added is just a form that makes a POST request to the logout route which logs the user out and redirects them to the login page.

Now create a file pages/api/logout.js and add the code:

import { withSessionAPI } from "@/lib/session";

function handler(request, response) {
request.session.destroy();
response.setHeader("location", "/login");
response.statusCode = 302;
response.end();
}

export default withSessionAPI(handler);

The code simply destroys the session and redirects the user to the login page.

Running the project:

In order to run the whole project, you will need to create a .env.local file in the root folder of the project. Inside the file, you need to put your MongoDB URI and Cookie Secret. The cookie secret is very important since it is what allows us to access the session stored in the client, if it changes between runs, the cookies are invalidated.

MONGODB_URI=<your-mongodb-uri>
COOKIE_SECRET=<your-32-character-long-secret>

Credits:

  1. Most of the things we did in this tutorial were made with help from the article by Ian Mitchell (Next.js and WebAuthn).
  2. The webauthn site was also very helpful — webauthn.io
  3. The webauthn guide — webauthn.guide

Thank You!

Thats all of it! You should now have a fully functional webauthn authentication system. Passkeys are an exciting feature I am looking forward to. Feel free to comment down below about any improvements I can make. Thank you for reading!

--

--

Vachan MN

Hey! I am Vachan, a full stack web developer committed to making apps that make a difference in my Users' lives. I develop bots, scripts and many cool things.