DEV Community

Cover image for Clerk with Authn (PassKeys) in NextJs
Roberto Yamanaka
Roberto Yamanaka

Posted on

Clerk with Authn (PassKeys) in NextJs

If you're like me, you already love using Clerk to handle security in your App.

However, one cool feature that is missing is the Authn/Passkey integration. Basically being able to login with your FaceId or Fingerprint. Which is pretty cool.

Since it is not coming soon to Clerk, I'm gonna show you how to hack a solution by yourself.

Setup a new Clerk Project

Go to your Clerk Dashboard and create a new project. I'm gonna call mine Passki (yes very creative) and select only the username for login (I'll later use this username as an unique identifier so keep this in mind).

New Clerk Project Design

Start a new NextJs Project

Lets go with the default settings and call my app Passki. I'll be using NextJs14 for this (currently NextJs' latest version).

What is your project named? my-app
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like to use `src/` directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
Would you like to customize the default import alias (@/*)? No / Yes
What import alias would you like configured? @/*
Enter fullscreen mode Exit fullscreen mode

Setup Clerk inside NextJs

Lets follow the steps in here to QuickStart our Clerk setup.

By now you should've done these from the QuickStart tutorial

  1. Add your env variables
  2. Wrap the app with your ClerkProvided
  3. Added the middleware.ts
  4. Added a simple <UserButton /> in your main App Page to login and logout

At the end of this blog post you can find the Repo for the project :)

Now to the fun part.

Strategize a solution

Understanding Authn

Authn is a new way of authenticating. Instead of relying on remembering your password you can login using a biometric, like your face or fingerprint.

This leads to a better User Experience since remembering passwords sucks.

Authn diagram how it works

How WebAuthn Works:

  1. Frontend requests a Challenge: When you try to log in, you ask the server for a unique challenge, which is essentially a random, one-time code.

  2. Activate Your Biometric: You then use your biometric (like a fingerprint or face scan) to respond. This action activates the private key stored securely on your device.

  3. Private Key Signs the Challenge: The private key, unlocked by your biometric, signs this challenge, turning it into a 'signed challenge.'

  4. Send Signed Challenge Back to the Server: Your device sends this signed challenge back to the server.

  5. Server Verifies with Public Key: The server uses the public key, which corresponds to your private key, to verify the signed challenge. If it matches, your identity is confirmed, and you are granted access.

How to approach Authn with code

First we will setup the necessary libraries for this project.

Next, we will register our fingerprint following the 5 steps above.

Lastly, we will sign in with the registered fingerprint also following the 5 steps from above.

Lets get to it!

Setup

We'll be using the @simplewebauthn libraries for the whole project. I've tested many libraries to implement Authn and this on is very straightforward and intuitive.

Go ahead and install these

npm install @simplewebauthn/server
Enter fullscreen mode Exit fullscreen mode
npm install @simplewebauthn/browser
Enter fullscreen mode Exit fullscreen mode
npm install @simplewebauthn/types
Enter fullscreen mode Exit fullscreen mode

We will also add our main entity PassKey. I will create a new path called /features/types.ts to save it for later use

export type PassKey = {
  credentialId: Uint8Array;
  credentialPublicKey: Uint8Array;
  counter: number;
  credentialDeviceType: string;
  credentialBackedUp: boolean;
  transports: Transport[];
  createdTs: number;
};

export type Transport =
  | 'ble'
  | 'cable'
  | 'hybrid'
  | 'internal'
  | 'nfc'
  | 'smart-card'
  | 'usb';
Enter fullscreen mode Exit fullscreen mode

This PassKey Type will simplify our pipeline since it is the bridge between our logic and the @simplewebauthn package.

We will also need to add some identifier constants so that Authn knows which are our authorized initializers. Let's save them under /features/constants

"user server"

// Human-readable title for your website
export const RP_NAME = 'Passki';

// A unique identifier for your website
export const RP_ID = 'localhost';

// The URL at which registrations and authentications should occur
export const RP_ORIGIN = 'http://localhost:3000';
Enter fullscreen mode Exit fullscreen mode

Lastly, this whole Authn pipeline requires us to keep track of an user's passkeys and their latest challenge (to prevent replay attacks). You would normally use a Database for that. But for the sake of this tutorial we are gonna use Clerk's inbuilt feature User's metadata.

The User's metadata allows us to save and access extra stuff from the User, which works really well for this tutorial like the PassKeys and the latest challenge.

In this case, we will use the private metadata since that can't be accessed from the frontend (only backend if you separate your environments well in NextJs).

Please note that an User's metadata is capped at 4kb, so better not abuse it haha.

Register your fingerprint

We should only allow registered users to add their fingerprint for extra security. Go ahead and add the custom Sign Up.

Create a new Sign Up page here app/sign-up/[[...sign-up]]/page.tsx and also add this env variable
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

The page.tsx component should look something like this

import { SignUp } from "@clerk/nextjs";

export default function SignUpPage() {
  return (
    <div className="h-screen flex items-center justify-center">
      <SignUp />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Good. That will allow us to sign up for the first time. Now let's add a new fingerprint for a User.

Let's create a new path called /features/register/ where we can create the server functions necessary to serve our Components.

Next, create a createRegisterOptions function which will generate Registration Options (including a new Challenge) for the user to register a new PassKey. I'll save this new function in features/register/create-registration-options.ts

'use server';
import { clerkClient, currentUser } from '@clerk/nextjs/server';
import { generateRegistrationOptions } from '@simplewebauthn/server';
import { PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/types';
import { PassKey } from '@/features/types';
import { RP_ID, RP_NAME } from '@/features/constants';

export async function createRegistrationOptions(): Promise<PublicKeyCredentialCreationOptionsJSON> {
  const user = await currentUser();
  if (!user || !user.username) {
    throw new Error('No user found');
  }
  // Generate WebAuthn registration options
  const userMetadata = user.privateMetadata;
  const userPassKeys = userMetadata.passKeys as PassKey[] || [];
  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userID: user.id,
    userName: user.username,
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred',
      authenticatorAttachment: 'platform',
    },
    excludeCredentials: userPassKeys.map((passKey) => ({
      id: passKey.credentialId,
      type: 'public-key',
    })),
  });

  // Update the latest challenge to prevent replay attacks
  await clerkClient.users.updateUserMetadata(user.id, {
    privateMetadata:{
      ...userMetadata,
      latestPassKeyChallenge: options.challenge,
    }
  });
  return options
}

Enter fullscreen mode Exit fullscreen mode

With those registerOptions, our frontend Component will begin the process to register a new Passkey. It will then call the startRegistration function and send it to validate to our endRegistration function. Let's also add the endRegistrationfunction.

'use server';

import { verifyRegistrationResponse } from '@simplewebauthn/server';
import { RP_ID, RP_ORIGIN } from '@/features/constants';
import { RegistrationResponseJSON } from '@simplewebauthn/types';
import { PassKey } from '../types';
import { clerkClient, currentUser } from '@clerk/nextjs';

export async function endRegistration(registrationResponseJSON: RegistrationResponseJSON) {
    const user = await currentUser();
    const userMetadata = user?.privateMetadata
    if (!user || !userMetadata) {
      throw new Error('No user found');
    }
    const expectedChallenge = userMetadata.latestPassKeyChallenge as string || ''
    try {
      const verification = await verifyRegistrationResponse({
        response: registrationResponseJSON,
        expectedChallenge,
        expectedOrigin: RP_ORIGIN,
        expectedRPID: RP_ID,
      });
      const { verified, registrationInfo } = verification;
      if (verified && registrationInfo) {
        console.log('Registration verified');
        const {
          credentialPublicKey,
          credentialID,
          counter,
          credentialBackedUp,
        } = registrationInfo;
        const foundTransports = registrationResponseJSON.response.transports;
        const newPassKey:PassKey = {
          credentialId: credentialID,
          credentialPublicKey: credentialPublicKey,
          counter,
          credentialDeviceType: 'singleDevice',
          credentialBackedUp,
          transports: foundTransports || [],
            createdTs: Date.now(),
        }
        // Save the new pass key to the user's private metadata
        const userPassKeys = userMetadata.passKeys as PassKey[] || [];
        await clerkClient.users.updateUserMetadata(user.id, {
          privateMetadata:{
            ...userMetadata,
            passKeys: [...userPassKeys, newPassKey],
          }
        });
        return true;
      } else {
        return false;
      }
    } catch (error) {
      console.error(error);
      return false;
    }
  }
Enter fullscreen mode Exit fullscreen mode


`

Let's create a new page called app/register-pass-key/page.tsx where we will first call the createRegistrationOptions function, then startRegistration and lastly call the endRegistrationfunction.

Here is a pretty Component using some Tailwind for that.

`

"use client";
import { useCallback } from "react";
import { createRegistrationOptions } from "@/features/register/create-registration-options";
import { startRegistration } from "@simplewebauthn/browser";
import { endRegistration } from "@/features/register/end-registration";

export default function RegisterPassKey() {
  const onClick = useCallback(async () => {
    const registrationOptions = await createRegistrationOptions();
    console.log("registrationOptions", registrationOptions);
    try {
      const registrationResponse = await startRegistration(registrationOptions);
      console.log("registrationResponse", registrationResponse);
      const registrationStatus = await endRegistration(registrationResponse);
      console.log("Registration successful", registrationStatus);
    } catch (error: unknown) {
      alert("Error: Authenticator was probably already registered by user");
      console.error("Error starting registration:", error);
      return;
    }
  }, []);

  return (
    <div className="relative isolate flex h-screen w-screen items-center justify-center overflow-hidden bg-gray-900">
      <div className="px-6 py-24 sm:px-6 sm:py-32 lg:px-8">
        <div className="mx-auto max-w-2xl text-center">
          <h2 className="text-4xl font-bold tracking-tight text-white sm:text-4xl">
            Setup your own Passkey
            <br />
          </h2>
          <p className="mx-auto mt-6 max-w-xl text-2xl leading-8 text-gray-300">
            You can use your fingerprint or FaceId to login to your account
          </p>
          <div className="mt-10 flex items-center justify-center gap-x-6">
            <button
              onClick={onClick}
              className="rounded-md bg-white px-3.5 py-2.5 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"
            >
              Lets do it!
            </button>
          </div>
        </div>
      </div>
      <svg
        viewBox="0 0 1024 1024"
        className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-x-1/2 [mask-image:radial-gradient(closest-side,white,transparent)]"
        aria-hidden="true"
      >
        <circle
          cx={512}
          cy={512}
          r={512}
          fill="url(#8d958450-c69f-4251-94bc-4e091a323369)"
          fillOpacity="0.7"
        />
        <defs>
          <radialGradient id="8d958450-c69f-4251-94bc-4e091a323369">
            <stop stopColor="#7775D6" />
            <stop offset={1} stopColor="#E935C1" />
          </radialGradient>
        </defs>
      </svg>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

Right now if you sign in and go to the register-pass-keypage you should be able to add your fingerprint to your Clerk account.

Add Fingerprint screen

Register Fingerprint Modal

If we go to our Clerk Dashboard we can see that indeed the LatestChallenge (now obsolete but still there) and the passKeysare there (only 1 for now).

Clerk User's Metadata

Great job!

Now, this was the easy part haha. We will now turn to the second part. Actually using your fingerprint to sign in.

Authentication

Good. As we did before let's create our Sign In Component.

Add to your .env.local this env variable
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in

And create the component inside app/sign-in/[[...sign-in]]/page.tsx

"use client";
import { useState } from "react";
import { SignIn } from "@clerk/nextjs";
import SignInPassKey from "@/features/authenticate/components/signInPassKey";

export default function SignInPage() {
  const [newScreen, setNewScreen] = useState(false);

  return (
    <>
      {newScreen ? (
        <SignInPassKey />
      ) : (
        <div className="h-screen flex flex-col items-center justify-center">
          <SignIn />
          <button
            onClick={() => setNewScreen(true)}
            type="button"
            className="mt-3 rounded-md bg-blue-700 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700"
          >
            Log in with my fingerprint 
          </button>
        </div>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

This Component has a child Component with our Authentication with Authn logic <SignInPassKey />. We will come back to this Component after we create the aux functions (aka the server functions).

Once again, we start by obtaining the *authentication options * to generate a New Challenge and other params.

In this case, we take the username as an input since we also need to update the user's metadata to the latest challenge (for extra security) and the username is the way we can identify the User.

Here is the code for that

'use server';
import { clerkClient } from '@clerk/nextjs/server';
import { generateAuthenticationOptions } from '@simplewebauthn/server';
import { PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
import { RP_ID } from '@/features/constants';

export async function createAuthenticationOptions(username:string): Promise<PublicKeyCredentialRequestOptionsJSON> {
  console.log('username input', username);
  if (!username) {
    throw new Error('No username provided for authentication options.');
  }
  const usernames = [username]

  const possibleUsers = await clerkClient.users.getUserList({ username:usernames });
  // A reasonable assumption since usernames should be unique
  const user = possibleUsers[0]

  // Generate WebAuthn authentication options
  const userMetadata = user.privateMetadata;
  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    userVerification: 'preferred',
  });

  // Update the latest challenge to prevent replay attacks
  await clerkClient.users.updateUserMetadata(user.id, {
    privateMetadata:{
      ...userMetadata,
      latestPassKeyChallenge: options.challenge,
    }
  });
  return options
}

Enter fullscreen mode Exit fullscreen mode

With those options, the frontend Component will run the Authentication pipeline and return to our Server the Authentication Response to validate it with endAuthentication.

Here is endAuthentication

"use server";
import { clerkClient } from "@clerk/nextjs";
import {
  AuthenticationResponseJSON,
  AuthenticatorDevice,
} from "@simplewebauthn/types";
import {
  base64URLStringToUint8Array,
  findPassKeyByCredentialId,
} from "./utils";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { RP_ID, RP_ORIGIN } from "../constants";
import { PassKey } from "../types";
import { createSignInToken } from "./create-sign-in-token";

export async function endAuthentication(
  authenticationResponseJSON: AuthenticationResponseJSON
) {
  const { id, response } = authenticationResponseJSON;
  const { userHandle } = response; // This is the user ID
  console.log("userHandle", userHandle);
  if (!userHandle) return;
  const user = await clerkClient.users.getUser(userHandle);
  const expectedChallenge =
    (user.privateMetadata.latestPassKeyChallenge as string) || "";
  const credentialId = base64URLStringToUint8Array(id);
  const userPassKeys = (user.privateMetadata.passKeys as PassKey[]) || [];
  if (!userPassKeys) return;
  const passKey = findPassKeyByCredentialId(credentialId, userPassKeys);
  if (!passKey) return "";
  const credentialPublicKey = passKey.credentialPublicKey as Uint8Array;
  const credentialID = passKey.credentialId;
  // You may have issues with this authenticator
  // by getting "no data" make sure your types are correct.
  // That is why the extra casting
  const authenticator: AuthenticatorDevice = {
    credentialPublicKey: new Uint8Array(Object.values(credentialPublicKey)),
    credentialID: new Uint8Array(Object.values(credentialID)),
    counter: passKey.counter,
    transports: passKey.transports,
  };

  try {
    const verification = await verifyAuthenticationResponse({
      response: authenticationResponseJSON,
      expectedChallenge,
      expectedOrigin: RP_ORIGIN,
      expectedRPID: RP_ID,
      authenticator,
    });
    console.log("verification response", verification);
    if (verification.verified) {
      const newSignInToken = await createSignInToken(user.id);
      return newSignInToken;
    }
  } catch (error) {
    console.error(error);
    return;
  }
}
Enter fullscreen mode Exit fullscreen mode

There are extra utils functions that help us with the parsing of Uint8's and making sure the data remains consisten accross the function.

In the end, you can see that once the Verification is Verified, we generate a Sign In Token with Clerk.

These Sign In Tokens are one time tokens to Sign In the App so they come in very handy for implementing Authn securely.

All right. now we have everything we need. Let's integrate it in the frontend Component

"use client";
import { useCallback, useState } from "react";
import { createAuthenticationOptions } from "@/features/authenticate/create-authentication-options";
import { startAuthentication } from "@simplewebauthn/browser";
import { endAuthentication } from "@/features/authenticate/end-authentication";
import { useSignIn } from "@clerk/nextjs";

export default function SignInPassKey() {
  const { signIn, setActive } = useSignIn();
  const [username, setUsername] = useState("");

  const passkeyAuthenticate = useCallback(async () => {
    const authenticationOptions = await createAuthenticationOptions(username);
    try {
      const authenticationResponse = await startAuthentication(
        authenticationOptions,
        false
      );
      console.log("authenticationResponse", authenticationResponse);
      const signInToken = await endAuthentication(authenticationResponse);
      console.log("sign in token", signInToken);
      if (!signIn || !signInToken) return;
      const res = await signIn.create({
        strategy: "ticket",
        ticket: signInToken as string,
      });
      console.log("auth response", res);
      setActive({
        session: res.createdSessionId,
      });
    } catch (error: unknown) {
      console.error("Error starting authentication:", error);
      return;
    }
  }, [username, setActive, signIn]);

  return (
    <div className="h-screen flex flex-col items-center justify-center">
      <div>
        <h2 className="text-4xl font-bold tracking-tight text-white sm:text-4xl">
          Log In with your PassKey
        </h2>
        <div className="justify-self-start mt-2">
          <label
            htmlFor="username"
            className="block text-sm font-medium leading-6 text-white text-2xl"
          >
            Username
          </label>
          <div className="mt-2 w-full">
            <input
              id="username"
              type="text"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
              className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
            />
          </div>
          <button
            type="button"
            onClick={passkeyAuthenticate}
            className="mt-3 rounded-md  w-full bg-blue-700 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700"
          >
            Log in with my fingerprint 
          </button>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

And there you have it! Lets try it out!

We go to the PassKey section

Login With PassKey

We choose our PassKey

Select PassKey from Auth Modal

And we Sign In!!!

Use your fingerprint

And just like that. We are signed in with our Fingerprint

Signed in screen

Next up

This was a lot of work. But we made it possible :)

If you have any comments or doubts, I'd be happy to discuss them on X with you.

Thanks for reading. In the next post I'll show you how to setup Clerk Phone SignUp in Expo React Native. It will be fun.

Until next time!
Roberto

Github Repo

Top comments (5)

Collapse
 
jescalan profile image
Jeff Escalante

This is really fantastic, we are really impressed by this work over at Clerk. Just wanted to let you know that hopefully your life will be made easier by our upcoming native support for passkeys, which is currently actively in the works!

Collapse
 
robertoyamanaka profile image
Roberto Yamanaka

Looking forward to this!! I thought it was later in the roadmap. This is great news

Collapse
 
brianmmdev profile image
Brian Morrison II

Nicely done!

Collapse
 
fmerian profile image
flo merian • Edited

great tutorial, @robertoyamanaka! using sign-in tokens is such a smart approach. 💡👏

edit: you're featured in our latest product update!

Collapse
 
cblberlin profile image
Bailin CAI

great job, just love it