My profile picture. My mom says I'm a handsome boy!
← All Posts

Next.js and WebAuthn

#Code, #Guides, #Web

Only writing two posts in 2022 was lame, I'm sorry. Let's kick off 2023 with a long post on a topic that I think a lot of people are interested in: WebAuthn. Specifically, let's add passkey authentication to a Next.js website!

Prerequisites and Sources

To make this post more focused, I'm going to make assume familiarity with WebAuthn, Sessions, Next.js, and Prisma. If you'd like additional resources on WebAuthn, I found the following websites extremely helpful while learning about it:

Prisma Database Schema

Create a new Next.js application and set up Prisma with an SQLite database. We'll use this to store user accounts and credentials (also known as passkeys).

Let's start by defining a User model. For demonstration purposes, we'll define a User as having an email and username (these two fields are not required by WebAuthn).

prisma/schema.prismaprisma
1model User {
2 id Int @id @default(autoincrement())
3 email String @unique
4 username String @unique
5
6 createdAt DateTime @default(now())
7 updatedAt DateTime @updatedAt
8}

Next, let's add credentials. We can expect users to only register with a single credential which Apple, Google, or another service then syncs across platforms and devices for the most part. For iOS, a credential would be a Passkey that is stored on Keychain and available on all connected devices. In order to allow for people to add multiple credentials to their accounts though, we should make credentials their own model and create a one-to-many relationship with Users.

Credentials are composed of an optional user-supplied nickname ("My Phone"), a credential ID, a public key, and a sign-in count. I tend to call the credential ID externalId to prevent confusion with the primary ID of the credential model record.

prisma/schema.prismaprisma
1model User {
2 id Int @id @default(autoincrement())
3 email String @unique
4 username String @unique
+ credentials Credential[]
6
7 createdAt DateTime @default(now())
8 updatedAt DateTime @updatedAt
9}
10
+model Credential {
+ id Int @id @default(autoincrement())
+ user User @relation(fields: [userId], references: [id])
+ userId Int
+
+ name String?
+ externalId String @unique
+ publicKey Bytes @unique
+ signCount Int @default(0)
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@index([externalId])
+}

With this done, run npx prisma migrate dev to create the database file. Next up, let's add sessions to Next.js!

Sessions

Next.js doesn't support sessions out of the box, so we'll need to add a dependency for this step. Install iron-session so that we can pass encrypted cookies to the server. Then create lib/session.ts and define the cookie.

lib/session.tsts
1import type { IronSessionOptions } from "iron-session";
2
3export const sessionOptions: IronSessionOptions = {
4 password: process.env.SECRET_COOKIE_PASSWORD!,
5 cookieName: "next-webauthn",
6 cookieOptions: {
7 secure: process.env.NODE_ENV === "production",
8 },
9};
10
11// Define the cookie structure globally for TypeScript
12declare module "iron-session" {
13 interface IronSessionData {
14 userId?: number;
15 challenge?: string;
16 }
17}

Substitute the name with whatever you want to call the cookie. You'll need to add a password to your .env.local file and production Environmental Variables.

We are also declaring the cookie structure (lines 12-17). We'll be storing two pieces of information on this cookie; the User ID and a challenge string. We'll set userId when a user has authenticated and logged in, and challenge as part of the login and registration flow (we'll cover that soon).

With these changes, we'll have a session object on API and SSR request variables that we can use to read stored cookie data!


As a sidenote, sessions are one area that makes the Remix framework interesting to me. Where Next.js focuses more on being a client framework, Remix has better out of the box support for server functionality like sessions. Adding WebAuthn to a Remix app is a lot more straightforward than I found it to be with Next.js!


Registration

With the database defined and sessions implemented, we're ready to add account registration!

Client

First we need to create a registration page. Let's create pages/register.tsx and prompt the user for their email and username.

pages/register.tsxtsx
1import { Fragment, useState } from "react";
2
3export default function Register() {
4 const [username, setUsername] = useState("");
5 const [email, setEmail] = useState("");
6
7 return (
8 <Fragment>
9 <h1>Register Account</h1>
10 <form method="POST">
11 <input
12 type="text"
13 id="username"
14 name="username"
15 placeholder="Username"
16 value={username}
17 onChange={(event) => setUsername(event.target.value)}
18 />
19 <input
20 type="email"
21 id="email"
22 name="email"
23 placeholder="Email"
24 value={email}
25 onChange={(event) => setEmail(event.target.value)}
26 />
27 <input type="submit" value="Register" />
28 </form>
29 </Fragment>
30 );
31}

While most browsers support WebAuthn, we should still add a fallback error message. Install @github/webauthn-json - while the check itself is minimal, we'll use this package to create and login users on the client as well1.

pages/register.tsxtsx
+import { Fragment, useEffect, useState } from "react";
+import { supported } from "@github/webauthn-json";
3
4export default function Register() {
5 const [username, setUsername] = useState("");
6 const [email, setEmail] = useState("");
+ const [isAvailable, setIsAvailable] = useState<boolean | null>(null);
8
+ useEffect(() => {
+ const checkAvailability = async () => {
+ const available =
+ await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
+ setIsAvailable(available && supported());
+ };
+
+ checkAvailability();
+ }, []);
18
19 return (
20 <Fragment>
21 <h1>Register Account</h1>
+ {isAvailable ? (
23 <form method="POST" onSubmit={onSubmit}>
24 // Form Here - snipping it for length
25 </form>
+ ) : (
+ <p>Sorry, WebAuthn is not available.</p>
+ )}
29 </Fragment>
30 );

As an improvement, you should check isAvailable for a null value and render a loading UI instead.

In order to register the user, we'll need a challenge code. This is a value generated on the server and passed to the client, so let's convert this page to SSR and generate a code. Create a new file called lib/auth.ts and write a function to create a secure base64 string challenge.

lib/auth.tsts
1import crypto from "node:crypto";
2
3function clean(str: string) {
4 return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
5}
6
7export function generateChallenge() {
8 return clean(crypto.randomBytes(32).toString("base64"));
9}

Then let's use this to add the challenge to the session in getServerSideProps.

pages/register.tsxtsx
1import { Fragment, useEffect, useState } from "react";
2import { supported } from "@github/webauthn-json";
+import { withIronSessionSsr } from "iron-session/next";
+import { generateChallenge } from "../lib/auth";
+import { sessionOptions } from "../lib/session";
6
-export default function Register() {
+export default function Register({ challenge }: { challenge: string }) {
9 // ...
10}
11
+export const getServerSideProps = withIronSessionSsr(async function ({
+ req,
+ res,
+}) {
+ const challenge = generateChallenge();
+ req.session.challenge = challenge;
+ await req.session.save();
+
+ return { props: { challenge } };
+},
+sessionOptions);

Now that our page has access to the server-generated challenge, we can write the form submit handler that creates the user account! We'll use a helper method from @github/webauthn-json to generate the JSON to send to a server, shoot a fetch request to our (not yet created) registration API endpoint, and then redirect the user to our admin page (also not yet created).

pages/register.tsxtsx
1// ...
+import { FormEvent, Fragment, useEffect, useState } from "react";
+import { supported, create } from "@github/webauthn-json";
+import { useRouter } from "next/router";
5
6export default function Register({ challenge }: { challenge: string }) {
+ const router = useRouter();
+ const [error, setError] = useState("");
9 // ...
10
+ const onSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+
+ // Create the credential
+ const credential = await create({
+ publicKey: {
+ challenge: challenge,
+ rp: {
+ // Change these later
+ name: "next-webauthn",
+ id: "localhost",
+ },
+ user: {
+ // Maybe change these later
+ id: window.crypto.randomUUID(),
+ name: email,
+ displayName: username,
+ },
+ // Don't change these later
+ pubKeyCredParams: [{ alg: -7, type: "public-key" }],
+ timeout: 60000,
+ attestation: "direct",
+ authenticatorSelection: {
+ residentKey: "required",
+ userVerification: "required",
+ },
+ },
+ });
+
+ // Call our registration endpoint with the new account details
+ const result = await fetch("/api/auth/register", {
+ method: "POST",
+ body: JSON.stringify({ email, username, credential }),
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+
+ // Redirect to the admin page or render errors
+ if (result.ok) {
+ router.push("/admin");
+ } else {
+ const { message } = await result.json();
+ setError(message);
+ }
+ };
57
58 return (
59 <Fragment>
60 <h1>Register Account</h1>
61 {isAvailable ? (
- <form method="POST">
+ <form method="POST" onSubmit={onSubmit}>
64 <input
65 type="text"
66 id="username"
67 name="username"
68 placeholder="Username"
69 value={username}
70 onChange={(event) => setUsername(event.target.value)}
71 />
72 <input
73 type="email"
74 id="email"
75 name="email"
76 placeholder="Email"
77 value={email}
78 onChange={(event) => setEmail(event.target.value)}
79 />
80 <input type="submit" value="Register" />
+ {error != null ? <pre>{error}</pre> : null}
82 </form>
83 ) : (
84 <p>Sorry, webauthn is not available.</p>
85 )}
86 </Fragment>
87 );
88}

Let's add one last UX touch. If the user is logged in, let's redirect them to the admin page.

lib/auth.tsts
1import crypto from "node:crypto";
+import { GetServerSidePropsContext, NextApiRequest } from "next";
3
+// Handle API and SSR requests
+type SessionRequest = NextApiRequest | GetServerSidePropsContext["req"];
6
7function clean(str: string) {
8 return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
9}
10
11export function generateChallenge() {
12 return clean(crypto.randomBytes(32).toString("base64"));
13}
14
+export function isLoggedIn(request: SessionRequest) {
+ return request.session.userId != null;
+}
pages/register.tsxts
1export const getServerSideProps = withIronSessionSsr(async function ({
2 req,
3 res,
4}) {
+ if (isLoggedIn(req)) {
+ return {
+ redirect: {
+ destination: "/admin",
+ permanent: false,
+ },
+ };
+ }
13
14 // ...
15},
16sessionOptions);
17

Server

We're finally ready to implement the registration API route! We're going to split this into two concerns; an API route that handles the request, and a function to register an account which we'll stick into our lib/auth.ts file.

First, let's create the API route. The route should register the account and then update the session with the account details.

pages/api/auth/register.tsxts
1import { withIronSessionApiRoute } from "iron-session/next";
2import { sessionOptions } from "../../../lib/session";
3import { NextApiRequest, NextApiResponse } from "next";
4import { register } from "../../../lib/auth";
5
6async function handler(request: NextApiRequest, response: NextApiResponse) {
7 try {
8 const user = await register(request);
9 request.session.userId = user.id;
10 await request.session.save();
11
12 response.json({ userId: user.id });
13 } catch (error: unknown) {
14 console.error((error as Error).message);
15 response.status(500).json({ message: (error as Error).message });
16 }
17}
18
19export default withIronSessionApiRoute(handler, sessionOptions);

Next, let's implement the register function. This step is more interesting - validating a credential is a chunk of work. I tried to read the spec and implement the algorithm myself, but it was such a slog that eventually I realized I enjoyed stubbing my toe more than I enjoyed trying to implement the function. The better choice is to install @simplewebauthn/server to do the work for you - Matthew is just smarter than me. If you'd like to give writing validation yourself a go, you can find the spec listing all the steps here. Don't say I didn't warn you!

So! Install @simplewebauthn/server and then create a register function in lib/auth.ts. This function should call verifyRegistrationResponse to see if the credentials are valid, and if they are we want to create a new record in the database.

lib/auth.tsts
1import type {
2 VerifiedAuthenticationResponse,
3 VerifiedRegistrationResponse,
4} from "@simplewebauthn/server";
5import {
6 verifyAuthenticationResponse,
7 verifyRegistrationResponse,
8} from "@simplewebauthn/server";
9import type {
10 PublicKeyCredentialWithAssertionJSON,
11 PublicKeyCredentialWithAttestationJSON,
12} from "@github/webauthn-json";
13
14const HOST_SETTINGS = {
15 expectedOrigin: process.env.VERCEL_URL ?? "http://localhost:3000",
16 expectedRPID: process.env.RPID ?? "localhost",
17};
18
19// Helper function to translate values between
20// `@github/webauthn-json` and `@simplewebauthn/server`
21function binaryToBase64url(bytes: Uint8Array) {
22 let str = "";
23
24 bytes.forEach((charCode) => {
25 str += String.fromCharCode(charCode);
26 });
27
28 return btoa(str);
29}
30
31export async function register(request: NextApiRequest) {
32 const challenge = request.session.challenge ?? "";
33 const credential = request.body
34 .credential as PublicKeyCredentialWithAttestationJSON;
35 const { email, username } = request.body;
36
37 let verification: VerifiedRegistrationResponse;
38
39 if (credential == null) {
40 throw new Error("Invalid Credentials");
41 }
42
43 try {
44 verification = await verifyRegistrationResponse({
45 response: credential,
46 expectedChallenge: challenge,
47 requireUserVerification: true,
48 ...HOST_SETTINGS,
49 });
50 } catch (error) {
51 console.error(error);
52 throw error;
53 }
54
55 if (!verification.verified) {
56 throw new Error("Registration verification failed");
57 }
58
59 const { credentialID, credentialPublicKey } =
60 verification.registrationInfo ?? {};
61
62 if (credentialID == null || credentialPublicKey == null) {
63 throw new Error("Registration failed");
64 }
65
66 const user = await prisma.user.create({
67 data: {
68 email,
69 username,
70 credentials: {
71 create: {
72 externalId: clean(binaryToBase64url(credentialID)),
73 publicKey: Buffer.from(credentialPublicKey),
74 },
75 },
76 },
77 });
78
79 console.log(`Registered new user ${user.id}`);
80 return user;
81}

And we're done! Next up, let's create the admin page.

Authentication

We can almost test our registration flow! Let's create the pages/admin/index.tsx Admin page that requires a logged-in user. There are a few ways to go about this, but the cleanest way is to add getServerSideProps and validate the session there. If the userId is not set, we redirect the user to the /login path.

pages/admin/index.tsxtsx
1import { withIronSessionSsr } from "iron-session/next";
2import { InferGetServerSidePropsType } from "next";
3import { Fragment } from "react";
4import { isLoggedIn } from "../../lib/auth";
5import { sessionOptions } from "../../lib/session";
6
7export default function Admin({
8 userId,
9}: InferGetServerSidePropsType<typeof getServerSideProps>) {
10 return (
11 <Fragment>
12 <h1>Admin</h1>
13 <span>User ID: {userId}</span>
14 </Fragment>
15 );
16}
17
18export const getServerSideProps = withIronSessionSsr(
19 async ({ req: request, res: response }) => {
20 if (!isLoggedIn(request)) {
21 return {
22 redirect: {
23 destination: "/login",
24 permanent: false,
25 },
26 };
27 }
28
29 return {
30 props: {
31 userId: request.session.userId ?? null,
32 },
33 };
34 },
35 sessionOptions
36);

If you register a new account, you should get automatically redirected to this page!

Logout

Next up, let's implement the logout flow. When a user logs out, we want to destroy the session and redirect the user to the login screen. Create the new file at pages/api/auth/logout.ts:

pages/api/auth/logout.tsts
1import { withIronSessionApiRoute } from "iron-session/next";
2import { sessionOptions } from "../../../lib/session";
3import { NextApiRequest, NextApiResponse } from "next";
4
5function handler(request: NextApiRequest, response: NextApiResponse) {
6 request.session.destroy();
7 response.setHeader("location", "/login");
8 response.statusCode = 302;
9 response.end();
10}
11
12export default withIronSessionApiRoute(handler, sessionOptions);

Then we can add a form to our admin page to logout users with a button click:

pages/admin/index.tsxtsx
1export default function Admin({
2 userId,
3}: InferGetServerSidePropsType<typeof getServerSideProps>) {
4 return (
5 <Fragment>
6 <h1>Admin</h1>
7 <span>User ID: {userId}</span>
+ <form method="POST" action="/api/auth/logout">
+ <button>Logout</button>
+ </form>
11 </Fragment>
12 );
13}

If you're surprised this works without JavaScript, I'd recommend reading through the Remix documentation on forms - Remix doesn't do anything special with them, but their documentation is very accessible and clear.

Login

At this point, you're able to register a new account, view a page only visible to logged in users, and logout by clicking a button. Let's add the ability to login with an existing account!

Client

The login page will follow a similar pattern as our registration page. Rather than break it into multiple parts, I'm going to list the entire completed file; the main difference is the highlighted submission handler. Create a new pages/login.tsx page with the following code.

pages/login.tsxtsx
1import { FormEvent, Fragment, useEffect, useState } from "react";
2import { supported, create, get } from "@github/webauthn-json";
3import { withIronSessionSsr } from "iron-session/next";
4import { generateChallenge, isLoggedIn } from "../lib/auth";
5import { sessionOptions } from "../lib/session";
6import { useRouter } from "next/router";
7
8export default function Login({ challenge }: { challenge: string }) {
9 const router = useRouter();
10 const [email, setEmail] = useState("");
11 const [error, setError] = useState("");
12 const [isAvailable, setIsAvailable] = useState<boolean | null>(null);
13
14 useEffect(() => {
15 const checkAvailability = async () => {
16 const available =
17 await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
18 setIsAvailable(available && supported());
19 };
20
21 checkAvailability();
22 }, []);
23
24 const onSubmit = async (event: FormEvent) => {
25 event.preventDefault();
26
27 // Retrieve a registered passkey from the browser
28 const credential = await get({
29 publicKey: {
30 challenge,
31 timeout: 60000,
32 userVerification: "required",
33 rpId: "localhost",
34 },
35 });
36
37 const result = await fetch("/api/auth/login", {
38 method: "POST",
39 body: JSON.stringify({ email, credential }),
40 headers: {
41 "Content-Type": "application/json",
42 },
43 });
44
45 if (result.ok) {
46 router.push("/admin");
47 } else {
48 const { message } = await result.json();
49 setError(message);
50 }
51 };
52
53 return (
54 <Fragment>
55 <h1>Login</h1>
56 {isAvailable ? (
57 <form method="POST" onSubmit={onSubmit}>
58 <input
59 type="email"
60 id="email"
61 name="email"
62 placeholder="Email"
63 value={email}
64 onChange={(event) => setEmail(event.target.value)}
65 />
66 <input type="submit" value="Login" />
67 {error != null ? <pre>{error}</pre> : null}
68 </form>
69 ) : (
70 <p>Sorry, webauthn is not available.</p>
71 )}
72 </Fragment>
73 );
74}
75
76export const getServerSideProps = withIronSessionSsr(async function ({
77 req,
78 res,
79}) {
80 if (isLoggedIn(req)) {
81 return {
82 redirect: {
83 destination: "/admin",
84 permanent: false,
85 },
86 };
87 }
88
89 const challenge = generateChallenge();
90 req.session.challenge = challenge;
91 await req.session.save();
92
93 return { props: { challenge } };
94},
95sessionOptions);

Server

Next, let's implement the login API route and method. Again, we'll split the code into two concerns, similar to what we did with the registration code. Start with the API route itself and create a new pages/api/auth/login.ts file.

pages/api/auth/login.tsts
1import { withIronSessionApiRoute } from "iron-session/next";
2import { sessionOptions } from "../../../lib/session";
3import { NextApiRequest, NextApiResponse } from "next";
4import { login } from "../../../lib/auth";
5
6async function handler(request: NextApiRequest, response: NextApiResponse) {
7 try {
8 const userId = await login(request);
9 request.session.userId = userId;
10 await request.session.save();
11
12 response.json(userId);
13 } catch (error) {
14 response.status(500).json({ message: (error as Error).message });
15 }
16}
17
18export default withIronSessionApiRoute(handler, sessionOptions);

Then we can add the login method to our auth file. This function requires a few additional steps than our registration function does; we need to find the right credential in our database, validate it with the credential sent by the browser, and then update the metadata of our record with a new sign in count.

lib/auth.tsts
1export async function login(request: NextApiRequest) {
2 const challenge = request.session.challenge ?? "";
3 const credential = request.body
4 .credential as PublicKeyCredentialWithAssertionJSON;
5 const email = request.body.email;
6
7 if (credential?.id == null) {
8 throw new Error("Invalid Credentials");
9 }
10
11 // Find our credential record
12 const userCredential = await prisma.credential.findUnique({
13 select: {
14 id: true,
15 userId: true,
16 externalId: true,
17 publicKey: true,
18 signCount: true,
19 user: {
20 select: {
21 email: true,
22 },
23 },
24 },
25 where: {
26 externalId: credential.id,
27 },
28 });
29
30 if (userCredential == null) {
31 throw new Error("Unknown User");
32 }
33
34 let verification: VerifiedAuthenticationResponse;
35 try {
36 // Verify browser credential with our record
37 verification = await verifyAuthenticationResponse({
38 response: credential,
39 expectedChallenge: challenge,
40 authenticator: {
41 credentialID: userCredential.externalId,
42 credentialPublicKey: userCredential.publicKey,
43 counter: userCredential.signCount,
44 },
45 ...HOST_SETTINGS,
46 });
47
48 // Update our record's sign in count
49 await prisma.credential.update({
50 data: {
51 signCount: verification.authenticationInfo.newCounter,
52 },
53 where: {
54 id: userCredential.id,
55 },
56 });
57 } catch (error) {
58 console.error(error);
59 throw error;
60 }
61
62 if (!verification.verified || email !== userCredential.user.email) {
63 throw new Error("Login verification failed");
64 }
65
66 console.log(`Logged in as user ${userCredential.userId}`);
67 return userCredential.userId;
68}

Closing Remarks

And with that, we're all done! Passkeys are a very interesting technology and I hope services start to adopt them. The developer experience for implementing them is a lot of work though, especially compared with traditional email/password accounts. I didn't even touch on recovery codes or adding new credentials to existing accounts, both of which a production implementation should have.

One thing that I found interesting is that (subjectively) Remix handles WebAuthn a little more elegantly than Next.js because of how server-intensive the authentication standard is. Remix has a lot of focus on server rendering and data loaders / action handling, and has sessions built in as a first-class API. I felt my Remix WebAuthn implementation was cleaner than the one I ended up with in Next.js.

Wrapping up, WebAuthn is tough! I spent a lot of time reading documentation on it, trying things out, and working through tricky bugs. I only got this far because I was able to continually bounce questions off my coworker @devsnek, who has the patience of a saint. Hopefully this guide helps others figure it out; as always, feel free to reach out with feedback or questions, I'm happy to talk!

You can find a working demo here.


  1. Despite requiring the payload to be sent to the server, credentials have no built in .toJSON() method. This is extremely frustrating to work with as a developer, and a surprisingly lapse in the web standard. The @github/webauthn-json package converts the clientside payload to JSON in the meantime; hopefully in the future it won't be needed.