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:
- WebAuthn Guide
- @github/webauthn-json Source Code
- Simple WebAuthn Documentation
- Passkeys on ImperialViolet
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 @unique4 username String @unique56 createdAt DateTime @default(now())7 updatedAt DateTime @updatedAt8}
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 @unique4 username String @unique+ credentials Credential[]67 createdAt DateTime @default(now())8 updatedAt DateTime @updatedAt9}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";23export 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};1011// Define the cookie structure globally for TypeScript12declare 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";23export default function Register() {4 const [username, setUsername] = useState("");5 const [email, setEmail] = useState("");67 return (8 <Fragment>9 <h1>Register Account</h1>10 <form method="POST">11 <input12 type="text"13 id="username"14 name="username"15 placeholder="Username"16 value={username}17 onChange={(event) => setUsername(event.target.value)}18 />19 <input20 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";34export 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();+ }, []);1819 return (20 <Fragment>21 <h1>Register Account</h1>+ {isAvailable ? (23 <form method="POST" onSubmit={onSubmit}>24 // Form Here - snipping it for length25 </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";23function clean(str: string) {4 return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");5}67export 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";56export 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);+ }+ };5758 return (59 <Fragment>60 <h1>Register Account</h1>61 {isAvailable ? (- <form method="POST">+ <form method="POST" onSubmit={onSubmit}>64 <input65 type="text"66 id="username"67 name="username"68 placeholder="Username"69 value={username}70 onChange={(event) => setUsername(event.target.value)}71 />72 <input73 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"];67function clean(str: string) {8 return str.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");9}1011export 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,+ },+ };+ }1314 // ...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";56async 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();1112 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}1819export 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";1314const HOST_SETTINGS = {15 expectedOrigin: process.env.VERCEL_URL ?? "http://localhost:3000",16 expectedRPID: process.env.RPID ?? "localhost",17};1819// Helper function to translate values between20// `@github/webauthn-json` and `@simplewebauthn/server`21function binaryToBase64url(bytes: Uint8Array) {22 let str = "";2324 bytes.forEach((charCode) => {25 str += String.fromCharCode(charCode);26 });2728 return btoa(str);29}3031export async function register(request: NextApiRequest) {32 const challenge = request.session.challenge ?? "";33 const credential = request.body34 .credential as PublicKeyCredentialWithAttestationJSON;35 const { email, username } = request.body;3637 let verification: VerifiedRegistrationResponse;3839 if (credential == null) {40 throw new Error("Invalid Credentials");41 }4243 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 }5455 if (!verification.verified) {56 throw new Error("Registration verification failed");57 }5859 const { credentialID, credentialPublicKey } =60 verification.registrationInfo ?? {};6162 if (credentialID == null || credentialPublicKey == null) {63 throw new Error("Registration failed");64 }6566 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 });7879 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";67export 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}1718export 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 }2829 return {30 props: {31 userId: request.session.userId ?? null,32 },33 };34 },35 sessionOptions36);
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";45function handler(request: NextApiRequest, response: NextApiResponse) {6 request.session.destroy();7 response.setHeader("location", "/login");8 response.statusCode = 302;9 response.end();10}1112export 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";78export 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);1314 useEffect(() => {15 const checkAvailability = async () => {16 const available =17 await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();18 setIsAvailable(available && supported());19 };2021 checkAvailability();22 }, []);2324 const onSubmit = async (event: FormEvent) => {25 event.preventDefault();2627 // Retrieve a registered passkey from the browser28 const credential = await get({29 publicKey: {30 challenge,31 timeout: 60000,32 userVerification: "required",33 rpId: "localhost",34 },35 });3637 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 });4445 if (result.ok) {46 router.push("/admin");47 } else {48 const { message } = await result.json();49 setError(message);50 }51 };5253 return (54 <Fragment>55 <h1>Login</h1>56 {isAvailable ? (57 <form method="POST" onSubmit={onSubmit}>58 <input59 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}7576export 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 }8889 const challenge = generateChallenge();90 req.session.challenge = challenge;91 await req.session.save();9293 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";56async function handler(request: NextApiRequest, response: NextApiResponse) {7 try {8 const userId = await login(request);9 request.session.userId = userId;10 await request.session.save();1112 response.json(userId);13 } catch (error) {14 response.status(500).json({ message: (error as Error).message });15 }16}1718export 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.body4 .credential as PublicKeyCredentialWithAssertionJSON;5 const email = request.body.email;67 if (credential?.id == null) {8 throw new Error("Invalid Credentials");9 }1011 // Find our credential record12 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 });2930 if (userCredential == null) {31 throw new Error("Unknown User");32 }3334 let verification: VerifiedAuthenticationResponse;35 try {36 // Verify browser credential with our record37 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 });4748 // Update our record's sign in count49 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 }6162 if (!verification.verified || email !== userCredential.user.email) {63 throw new Error("Login verification failed");64 }6566 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.
- 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.↩