👈 back
7 April 2023

Going full passwordless

Passwordless authentication?

Ever since the major tech companies announced that they will support passwordless authentication, hopes have been high that we will finally be able to get rid of passwords.

Passwordless authentication is a method of authentication that eliminates the need for a password. Instead, it uses other means such as biometric data (like fingerprints or facial recognition), a hardware token to verify a user's identity.

You have probably already used passwordless authentication without even realizing it. For example, you can use your fingerprint to unlock your phone or use your face to log into your computer using.

Why do we need it?

Passwords have been the go-to authentication method for decades, but they have proven to be unreliable and easy to hack. In addition, many users struggle to remember complex passwords, leading to the use of weak passwords or password reuse across multiple accounts. Password managers have helped to mitigate this issue, but they are not foolproof and can still be vulnerable to scams.

Passwordless authentication offers a more secure and convenient alternative to passwords. It reduces the risk of phishing and other scams, as there is no password to steal or trick users into revealing.

Why is it not possible to get rid of passwords yet?

Although passwordless authentication is a promising solution, it is not yet possible to get rid of passwords completely. Not all devices support biometric authentication or hardware tokens, and a backup authentication method is needed in case the primary method fails. High-risk services like banking and social media will still require some form of password or pin code for added security.

Doing it anyway

I decided to implement passwordless authentication anyway. Yes, not every user will be able to use my service, so this is not a production-ready solution. But it is was a good way to learn how to implement passwordless authentication and to get a feel for how it works.

The project

I am building a project called IMD2, which (you can guess from the name) is a IMDB clone.
When im happy with the project, I will write a blog post about it. But for now, I will focus on the authentication part.

IMD2 sign-up page

The sign-up page of my IMDB clone

Yes there's still a "defaut" way to sign up, but I will remove that later. So the it will be truly passwordless.

I will use WebAuthN to implement passwordless authentication. WebAuthN is a W3C standard that allows browsers to communicate with authenticators using JavaScript. It is supported by all major browsers and operating systems.
Implementing WebAuthN can be a bit confusing, and since the spec is still evolving(now on Level 2 but Level 3 is currently being worked on), different packages and libraries can have different implementations.

I used simplewebauthn for the front and backend. It is a simple and easy to use package that abstracts away the some of the complexity of WebAuthN. And because both the back and and frontend are both written by the same people, I know that they use the same spec. You can build you own implementation from scratch, but I would only reccomend that when the spec is more stable. If you want to know what is going on under the hood, you can read the github repo of simplewebauthn.

Okay, this may seem lazy but for a good tutorial litterally just follow the documentation on the simplewebauthn website. This is exactly what I did, bear in mind that I use Prisma with Postgres as my database.

I will explain my specific stack and how I implemented it, but the documentation is very good and you can use it for almost any stack.

Models

My Prisma models:

model User {  id            Int         @id @default(autoincrement())  email         String      @unique  password      String?  role          Role        @default(USER)  profile       Profile?  credentials   Authenticator[]  tokenVersion  Int         @default(0)  createdAt     DateTime    @default(now())  updatedAt     DateTime    @updatedAt}
model Authenticator {  credentialID          String      @id @unique  credentialPublicKey   Bytes  counter               BigInt  credentialDeviceType  String  credentialBackedUp    Boolean  transports            String[]  user                  User        @relation(fields: [userId], references: [id])  userId                Int  createdAt             DateTime    @default(now())  updatedAt             DateTime    @updatedAt}

The User model is pretty standard, but the Authenticator model is a bit more complex:

  • Authenticator is used to store the credentials of the authenticator.
  • credentialID is the unique identifier of the authenticator.
  • credentialPublicKey is the public key of the authenticator.
  • counter is to prevent replay attacks.
  • credentialDeviceType is the type of device that the authenticator is connected to.
  • credentialBackedUp is a boolean that indicates if the authenticator has been backed up.
  • transports is an array of strings that indicate the transport methods that the authenticator supports.
  • user is the user that the authenticator belongs to.
  • createdAt and updatedAt are the timestamps of when the authenticator was created and updated.

Sign-up

My WebauthN initialize and finish functions in my controller:

// WebauthN register initializeexport const webauthnRegisterInitialize = async (req: Request, res: Response) => {    const { name, email }: { name: string; email: string } = req.body;    // Check if user exists    const user = await prisma.user.findUnique({        where: {            email: email        },        include: {            credentials: true        }    });        // get the max id from the database using prisma    const maxId = (await prisma.user.findMany({        orderBy: {            id: 'desc'        },        take: 1    }))[0].id;    try {                const options = generateRegistrationOptions({            rpName: process.env.rpName ?? '',            rpID: process.env.rpID ?? '',            userID: user ? user.id.toString() : (maxId + 1).toString(),            userName: email,            userDisplayName: name,            attestationType: 'none',            // Prevent users from re-registering existing authenticators            excludeCredentials: user ? user.credentials.map(authenticator => ({                id: base64StringToUint8Array(authenticator.credentialID),                type: 'public-key',                transports: authenticator.transports as AuthenticatorTransportFuture[],            })) : [],            authenticatorSelection: {                residentKey: 'required',                userVerification: 'preferred',            }        });                  // Remember the challenge for this user        potentialUsersAndCredentialIds[email] = {            name: name,            challenge: options.challenge        };        return res.json(options);    }    catch(e) {        console.log(e);        return res.status(500).send({ ok: false });    }};
// WebauthN register finalizeexport const webauthnRegisterFinalize = async (req: Request, res: Response) => {    const { email, attestation } = req.body;    try {        const verification = await verifyRegistrationResponse({            response: attestation,            expectedChallenge: potentialUsersAndCredentialIds[email].challenge,            expectedOrigin: process.env.origin ?? '',            expectedRPID: process.env.rpID ?? '',            requireUserVerification: true,        });                const { verified, registrationInfo } = verification;                if (!verified) res.status(500).send({ ok: false });                if (!registrationInfo) res.status(500).send({ ok: false });        await prisma.authenticator.upsert({            where: {                credentialID: uint8ArrayToBase64String(registrationInfo!.credentialID)            },            create: {                credentialID: uint8ArrayToBase64String(registrationInfo!.credentialID),                credentialPublicKey: Buffer.from(registrationInfo!.credentialPublicKey),                counter: registrationInfo!.counter,                credentialDeviceType: registrationInfo!.credentialDeviceType,                credentialBackedUp: registrationInfo!.credentialBackedUp,                user: {                    connectOrCreate: {                        where: {                            email: email                        },                        create: {                            email: email,                            profile: {                                create: {                                    name: potentialUsersAndCredentialIds[email].name!,                                    avatarUrl: '',                                }                            }                        }                    }                }            },            update: {                credentialID: uint8ArrayToBase64String(registrationInfo!.credentialID),                credentialPublicKey: Buffer.from(registrationInfo!.credentialPublicKey),                counter: registrationInfo!.counter,                credentialDeviceType: registrationInfo!.credentialDeviceType,                credentialBackedUp: registrationInfo!.credentialBackedUp,                user: {                    connectOrCreate: {                        where: {                            email: email                        },                        create: {                            email: email,                            profile: {                                create: {                                    name: 'No Name',                                    avatarUrl: '',                                    bio: 'Write your bio here',                                }                            }                        }                    }                }            }        });        delete potentialUsersAndCredentialIds[email];                return res.status(200).send({ ok: true });    }    catch(e) {        console.log(e);        return res.status(500).send({ ok: false });    }};

The webauthnRegisterInitialize function takes the user's name and email as parameters and returns the options that are needed to register the authenticator:

  1. We check if the user exists in the database. If the user exists, we get the user id and use it as the user id for webauthn.
  2. If the user does not exist, we get the max user id from the database and add 1 to it. We use this value as the user id for webauthn.
  3. We generate the webauthn options. Use excludeCredentials to prevent users from re-registering existing authenticators!
  4. We save the challenge for the user in the potentialUsersAndCredentialIds object.

The webauthnRegisterFinalize function takes the user's email and the attestation object from the fontend as parameters and returns 200 or 500 status codes depending on if the registration was successful or not:

  1. We verify the response from the browser.
  2. If the response is verified, we will create or add a new authenticator to the database.
  3. It will delete the user from the potentialUsersAndCredentialIds object.

This is what I do on the frontend:

async registerWebauthn() {    // do the init call    const initializeRegisterWebauthnRes = await this.auth.initializeRegisterWebauthn(this.name, this.email);    // start the registration    const registrationResponse = await startRegistration(initializeRegisterWebauthnRes);    // do the finalize call    this.auth.finalizeRegisterWebauthn(this.email, registrationResponse);}

where startRegistration is imported from @simplewebauthn/browser and initializeRegisterWebauthn and finalizeRegisterWebauthn are functions that are shown above.

And voila! I a user can now register using WebauthN.

Now let's move on to the login process.

Sign-in

Again here are my WebauthN initialize and finish functions in my controller:

// WebauthN login initializeexport const webauthnLoginInitialize = async (req: Request, res: Response) => {    const { email } = req.body;    const user = await prisma.user.findUnique({        where: {            email: email        },        include: {            credentials: true        }    });    try {        const options = generateAuthenticationOptions({            // Require users to use a previously-registered authenticator            allowCredentials: user ? user.credentials.map(authenticator => ({                id: base64StringToUint8Array(authenticator.credentialID),                type: 'public-key',                // Optional                transports: authenticator.transports as AuthenticatorTransportFuture[],            })) : [],            userVerification: 'preferred',        });        potentialUsersAndCredentialIds[email] = {            challenge: options.challenge        };        return res.json(options);    }    catch(e) {        console.log(e);        return res.status(500).send({ ok: false });    }};
// WebauthN login finalizeexport const webauthnLoginFinalize = async (req: Request, res: Response) => {    const { email, attestation } = req.body;    try {        const user = await prisma.user.findUnique({            where: {                email: email            },            select: {                id: true,                tokenVersion: true,                credentials: {                    where: {                        credentialID: attestation.id                    }                }            },        });        if (!user || !user.credentials) return res.status(500).send({ ok: false });                const verification = await verifyAuthenticationResponse({            response: attestation,            expectedChallenge: potentialUsersAndCredentialIds[email].challenge,            expectedOrigin: process.env.origin ?? '',            expectedRPID: process.env.rpID ?? '',            authenticator: {                credentialID: base64StringToUint8Array(user!.credentials![0].credentialID),                credentialPublicKey: user!.credentials![0].credentialPublicKey,                counter: Number(user!.credentials![0].counter),            },            requireUserVerification: true,        });        const { verified, authenticationInfo } = verification;        if (!verified) return res.status(500).send({ ok: false });        if (!authenticationInfo) return res.status(500).send({ ok: false });        const authenticatorUpdateCount = await prisma.authenticator.update({            where: {                credentialID: uint8ArrayToBase64String(authenticationInfo!.credentialID)            },            data: {                counter: authenticationInfo!.newCounter            }        });        if (!authenticatorUpdateCount) return res.status(500).send({ ok: false });        delete potentialUsersAndCredentialIds[email];        // generate and send the access token here. I'm not going to show it here for obvious reasons.        return res.status(200).json({            accessToken: ''        });    }    catch(e) {        console.log(e);        return res.status(500).send({ ok: false });    }};

The webauthnLoginInitialize function takes the user's email as a parameter and returns the options that are needed to login the user:

  1. We check if the user exists in the database. If the user does not exist, we return 500 status code.
  2. We generate the webauthn options. Use allowCredentials to prevent users from logging in with an authenticator that is not registered to their account!
  3. We save the challenge for the user in the potentialUsersAndCredentialIds object.

The webauthnLoginFinalize takes the user's email and the attestation object as parameters and returns 200 or 500 status codes depending on if the login was successful or not:

  1. We find the user in the database. If the user does not exist, we return 500 status code.
  2. We verify the response from the browser.
  3. If the response is verified, we will update the counter of the authenticator in the database.
  4. It will delete the user from the potentialUsersAndCredentialIds object.

Again the frontend:

async loginWebauthn() {    // do the init call    const initializeLoginWebauthnRes = await this.auth.initializeLoginWebauthn(this.email);    // start the login    const loginResponse = await startAuthentication(initializeLoginWebauthnRes);    // do the finalize call    this.auth.finalizeLoginWebauthn(this.email, loginResponse);}

where again startAuthentication is imported from @simplewebauthn/browser and initializeLoginWebauthn and finalizeLoginWebauthn are functions that are shown above.

And voila! I a user can now login using WebauthN.

Conclusion

After writing this article I already have a few ideas on how to improve my code. It's not the cleanest code and I'm still learning the WebauthN spec.
If you have any suggestions please contact me!