PassKey

PassKey Only Auth?

Ethan Glover

What if your app's sign in and sign up form both looked like this?

Sign Up/In form with only a username field

I'm working on rebuilding Introspect.dev from scratch with Astro after some major disappointments with NextJS 13and the direction app router is taking. Astro has been a huge boost for me and I find it to be the meta-framework that I wished NextJS was. UI library agnostic, simple and clear APIs, easy build outputs deployable anywhere.

Originally with Introspect I was using Clerk.dev for authentication because Vercel had advertised them as an early supporter of app router. It turned out to be one of the many bottlenecks on that site. I've never really liked OAuth and it's implementation, but Clerk made it easy. I thought about bringing that over to the new site, but after playing around with it, it's actually kind of difficult to get it working with plain JS.

So I went back to my default. Just simple password signups and hashing. But then I came across something I've been reading about for a while again. PassKeys and webauthn. I'm already using PassKeys to sign in to GitHub without a password. It's like skipping straight to 2FA without typing in the password first.

Using passkeysinstead of passwords is a great way for websites to make their user accounts safer, simpler, easier to use and passwordless. With a passkey, a user can sign in to a website or an app just by using their fingerprint, face or device PIN.

- web.dev

The Auth Flow

It took me some tinkering to figure out how this all comes together. It seems a bit complex to have to set up 2 API endpoints for a sign-in, and 2 API endpoints for a sign-up. But once you get it all together it makes a lot more sense. And each step isn't difficult to abstract out thanks to the @simplewebauthn NPM packages.

Flowchart showing the steps we will discuss below

1. Check for Existing Users: Given the form above with ‘Check Username’ I do a simple database lookup to see if a user already exists with that username. This tells me if the user is signing up, or in.

2. Generate Registration Options (Start Sign Up): Part of how this works is I have to create a user in the database first before creating authentication. This may not be necessary for you, for me the only reason is because I want to use the generated UUID on that user as a userID for the credentials. After the credentials are created, I'll have to update that user again with the challenge provided by the credentials.

If the user is not found after clicking check username, two buttons appear to try another or sign up.
1const user = await context.prisma.user.create({
2  data: { username }
3});
1import { generateRegistrationOptions } from '@simplewebauthn/server';
2
3const options = await generateRegistrationOptions({
4  attestationType: 'none',
5  excludeCredentials: user.authenticators.map(authenticator => {
6    return {
7      id: base64urlDecode(authenticator.credentialID),
8      transports:
9        authenticator.transports,
10      type: 'public-key',
11    };
12  }),
13  rpID: relyingPartyId,
14  rpName: relyingPartyName,
15  userID: user.id,
16  userName: user.username,
17});
1await context.prisma.user.update({
2  data: {
3    currentChallenge: options.challenge,
4  },
5  where: {
6    username,
7  },
8});

So the only things I'm storing so far is a username and a challenge, and the only thing the user has had to give me is a username. The “challenge” here is just a server generated ArrayBuffer that will later be stored as a base64 encoded string.

3. Start Registration (Client Generated Key): Now we just need the browser to take the generated options to generate a public key. This requires no more input from the user, it is essentially using navigator.credentials.create(). Then we just take the client generated credentials and relay that right back to the server.

1import { startRegistration } from '@simplewebauthn/browser';
2
3// Pseudocode
4const data = await fetch('/generate-registration-options', 
5  { body: JSON.stringify({username}) }
6);
7
8const registration = await startRegistration(data);
9
10// Pseudocode
11await fetch('/verify-registration-response',
12  { body: JSON.stringify(registration) }
13);

4. Verify Registration Response (Finish Registration): Finally we can verify the client generated credentials with the users current challenge and add some other identifying information like origin and relying party id.

1import { verifyRegistrationResponse } from '@simplewebauthn/server';
2
3const user = await prisma.user.findUnique({
4  where: {
5    username,
6  },
7});
8
9const verification = await verifyRegistrationResponse({
10  expectedChallenge: user.currentChallenge, // Saved to database in step 2
11  expectedOrigin: 'http://localhost:3000', // App origin
12  expectedRPID: 'localhost', // App hostname
13  response: registration, // Results from startRegistration() in previous step
14});
15
16const { registrationInfo } = verification;
17const {
18  credentialBackedUp,
19  credentialDeviceType,
20  credentialPublicKey,
21  credentialID,
22  counter,
23} = registrationInfo;
24
25// Save authenticator data for sign in verification later
26await prisma.authenticator.create({
27  data: {
28    counter, // Number tracks how often device is used, helps find bad actors
29    credentialBackedUp, // Boolean
30    credentialDeviceType, // 'singleDevice' | 'multiDevice'
31    credentialID: base64urlEncode(credentialID), // Uint8Array stored as base64
32    credentialPublicKey: Buffer.from(credentialPublicKey), // Uint8Array stored as Blob/Bytes
33    userId: user.id, // UUID/PK from our own Users table
34  },
35});

Once verification is successful, we can safely log the user in. Such as signing a JWT token and setting a cookie. Their identity has been verified by encrypted keys and a handshake between two physical devices. Our server, and their phone or computer. They'll be able to sign in in the future with a fingerprint or PIN code on their device. We don't have to save a password, or sync an id from a third party service. All we need is a username. Which brings us to signing in.

5. Generate Authentication Options (Start Sign In): At this stage, all we have to do is prompt the user to sign in with their device, and verify it's public keys with the authenticators the user has previously used.

If a user exists, the user is given the option to click sign in to prompt their device for authentication
1import { generateAuthenticationOptions } from '@simplewebauthn/server';
2
3const user = await context.prisma.user.findUnique({
4  select: {
5    authenticators: {
6      select: {
7        credentialID: true,
8        transports: true,
9      },
10    },
11  },
12  where: { username },
13});
14
15const options = await generateAuthenticationOptions({
16  allowCredentials: authenticators.map(authenticator => { // Allow any previously used devices
17    return {
18      id: base64urlDecode(authenticator.credentialID), // We encoded this Uint8Array earlier
19      transports: authenticator.transports,
20      type: 'public-key',
21    };
22  }),
23  userVerification: 'preferred',
24});
25
26await context.prisma.user.update({
27  data: {
28    currentChallenge: options.challenge, // Update to a new challenge string to use on next sign in
29  },
30  where: {
31    username,
32  },
33});

6. Start Authentication (Client Generated Key): Very similar to how we generated a key on the client during sign up, we just do the same for sign in and relay back to the server. No more user input needed other than using the fingerprint on their phone, or PIN on their computer.

1import { startAuthentication } from '@simplewebauthn/browser';
2
3// Pseudocode
4const data = await fetch('/generate-authentication-options', 
5  { body: JSON.stringify({username}) }
6);
7
8const authentication = await startAuthentication(data);
9
10// Pseudocode
11await fetch('/verify-authentication-response',
12  { body: JSON.stringify(authentication) }
13);

7. Verify Registration Response (Finish Sign In): And again, very similar to the sign up process, we check the users current challenge to the device generated key and make sure the origin is the same.

1import { verifyRegistrationResponse } from '@simplewebauthn/server';
2
3const user = await prisma.user.findUnique({
4  where: {
5    username,
6  },
7});
8
9const authenticator = user.authenticators.filter(
10  // Users can have multiple authenticators
11  // Filter this to find the one that matches the data.id
12 // (data is from the client sent by the previous step)
13);
14
15const verification = await verifyRegistrationResponse({
16  authenticator: {
17    counter: authenticator.counter,
18    credentialID: base64urlDecode(authenticator.credentialID),
19    credentialPublicKey: authenticator.credentialPublicKey,
20    transports: authenticator.transports,
21  },
22  expectedChallenge: user.currentChallenge,
23  expectedOrigin: 'http://localhost:3000',
24  expectedRPID: 'localhost',
25  response: data,
26});
27
28await context.prisma.authenticator.update({
29  data: {
30    counter: authenticationInfo.newCounter,
31  },
32  where: {
33    id: authenticator.id,
34  },
35});

Same as with sign up, from here it's safe to generate a JWT token with an expires time and set a cookie.

When I first read about this, it felt like a lot of complicated steps. But honestly this is so much easier than OAuth. Setting up app keys with multiple APIs, callback endpoints, ugh. I am not a fan. webauthn is a standard supported by every major browser. No third parties needed. We're making use of the native Web Authentication API.

Should every site be using this? I'm not sure. While GitHub and Google both make use of PassKeys, accounts still fallback on passwords. That will always be the case for at the very least for the accounts used to login to devices. So basically the same companies we're using for OAuth. Google, Microsoft and Apple. But the benefit to this for other apps is, you don't have to deal with their APIs.