Simplifying Online Security: My Journey with Passkey Signup

Khayrul Alam
7 min readMay 27, 2024

--

Introduction

Security is the most crucial factor in any application. In the recent ever-evolving landscape of online security, a groundbreaking innovation has emerged: passkeys.

Nowadays everyone is talking about passkeys and moving their applications towards passkey signup. Major companies like Apple, Google, Amazon, and Microsoft are transitioning their signup methods from traditional to passkey signup, setting new standards for online security.

To expand my knowledge and gain hands-on experience with passkeys, I have created a small passkey application with both front-end and back-end components. In this blog, I’ll take you through my journey of developing a passkey application, detailing the challenges encountered and the valuable insights gained.

What are Passkeys?

Passkeys, also known as security keys, are hardware devices used for authenticating users. Unlike traditional passwords, which can be forgotten, stolen, or hacked, passkeys provide a more secure and user-friendly method of authentication. They are often used in conjunction with biometric data or PINs to add an extra layer of security.

How Passkeys Work?

The authentication process with passkeys involves two key phases: attestation and assertion.

  • Attestation Phase: This is the initial registration phase where the user sets up their passkey with the application. During this phase, the passkey generates a unique public-private key pair. The public key is sent to the server along with an attestation object, which includes metadata about the passkey and the cryptographic proof that the public key is associated with a legitimate device.
  • Assertion Phase: This is the authentication phase. When a user attempts to log in, the server sends a challenge to the user’s device. The passkey uses its private key to sign this challenge and sends the signed response back to the server. The server then verifies this response using the previously stored public key to authenticate the user.

Enhanced Security

  • Less Susceptible to Phishing: Since passkeys don’t rely on passwords, they eliminate the risk of password theft through phishing attacks.
  • No Passwords to Remember: Users don’t need to remember complex passwords, which simplifies the login process and reduces the likelihood of weak passwords.
  • Multi-Factor Authentication: Passkeys can be used as part of a multi-factor authentication system, adding an additional layer of security.

# My Project: Implementing Passkey Signup

Getting Started

To start with my project, I needed to understand the basics of passkeys and their implementation. I have researched various types of passkeys and their respective protocols, such as FIDO2 and WebAuthn.

Tools and Technologies Used

- Frontend: 
- NextJS
- Typescript
- Tailwind
- Backend:
- Node
- Express
- PostgreSQL
- TypeOrm
- WebAuthn

Application Diagram

Registration (Attestation Phase)

Registration Diagram

Login (Assertion Phase)

Login Diagram

# Registration (Attestation Phase)

Browser UI

To begin the registration process, we need to collect the user’s email, first name, and last name. Once we have this information, we will send it to the server to generate a random challenge. To simplify the form handling, we are using the react-hook-form library.

Registration Step one
export const getChallenge = async (
data: RegisterForm
): Promise<ChallengeData | undefined> => {
try {
const response = await axios.post<ChallengeData>("/get-challenge", data);
return response.data;
} catch (error: any) {
console.error("getChallenge Error:: ", error.message);
}
};

On the server side, I generate 32 random bytes using the `crypto` library and then convert the buffer to a base 64 string.

export const Challenge = {
create: async () => {
const randomChallenge = await crypto.randomBytes(32);
return BufferToBase64Url(randomChallenge);
},
};

After generating a random challenge, I save it in the session for future reference and respond to the client.

export const CreateChallenge = async (req: any, res: Response) => {
const requestBody: IUser = req.body;
try {
const sID = req.sessionID;
const challenge = await Challenge.create();
req.session[sID] = {
challenge,
user: requestBody,
};
return res.status(201).json({
challenge: req.session[sID].challenge,
user: {
id: Math.random().toString(36).substring(2),
name: requestBody.email,
displayName: requestBody.email,
},
});
} catch (error: any) {
return res.status(500).json({ error: error?.message });
}
};

The Response look like this:

get-challenge API response preview

After receiving a random challenge, the next step is to generate credentials using the web API: navigator.credentials.create

Registration step two
export const newCredentials = async (
challenge: ChallengeData
): Promise<IPublicKeyCredential | undefined> => {
try {
const credential = await navigator.credentials.create({
publicKey: {
challenge: Base64ToUnit8Array(challenge.challenge),
rp: {
name: RP_NAME,
id: RPID,
},
user: {
id: Base64ToUnit8Array(challenge.user.id),
name: challenge.user.name,
displayName: challenge.user.displayName,
},
pubKeyCredParams: [
{
type: "public-key",
alg: -7,
},
{
type: "public-key",
alg: -257,
},
{
type: "public-key",
alg: -8,
},
],
authenticatorSelection: {
userVerification: "required",
},
timeout: 60000,
attestation: "direct",
},
});
return credential as IPublicKeyCredential;
} catch (error: any) {
console.error("newCredentials Error:: ", error.message);
}
};
  • Challenge: challengeFromServer
  • RP.id: need to equal the origin’s
More Read
  • PubKeyCredParams:

An Array of objects which specify the key types and signature algorithms the Relying Party supports, ordered from most preferred to least preferred. The client and authenticator will make a best-effort to create a credential of the most preferred type possible. These objects will contain the following properties:

alg

A number that is equal to a COSE Algorithm Identifier, representing the cryptographic algorithm to use for this credential type. It is recommended that relying parties that wish to support a wide range of authenticators should include at least the following values in the provided choices:

-8: Ed25519

-7: ES256

-257: RS256

type

A string defining the type of public key credential to create. This can currently take a single value, "public-key", but more values may be added in the future.

If none of the listed credential types can be created, the create() operation fails.

When using the web authentication API, the navigator.credentials.create({ publicKey }) will open a popup screen.

Choose option popup

Choosing the Chrome profile will display this pop-up screen.

Create passkey popup

Afterward, you will be prompted to use Touch ID or enter your password.

Touch ID Screen

Then a successful create call returns a promise that resolves with a public key credential object.

Credential Object log

Now, with this credential object, I am going to the server to verify and store the public key and credential ID.

Registration step three
export const registerUser = async (newCred: IPublicKeyCredential) => {
try {
const payload = {
id: newCred.id,
rawId: BufferToBase64Url(newCred.rawId),
response: {
clientDataJSON: BufferToBase64Url(newCred.response.clientDataJSON),
attestationObject: BufferToBase64Url(newCred.response.attestationObject),
},
type: newCred.type,
};
const response = await axios.post("/register", payload);
return response.data;
} catch (error: any) {
console.error("registerUser Error:: ", error.message);
}
};

Request Payload:

Register payload

Note: Before sending it to the server, remember to convert the array buffer to a base64 string.

VerifyRegisterChallenge:

export const verifyRegisterChallenge = async (
challenge: string,
response: IRegisterRequest
) => {
try {
// verify registration response
const { verified, registrationInfo } = await verifyRegistrationResponse({
response,
expectedChallenge: challenge,
expectedOrigin: EXPECTED_ORIGIN,
expectedRPID: RPID,
});
// check if response is valid
if (!registrationInfo) {
throw new Error("Invalid Registration information");
}
// return verified and registration information
return {
verified,
registerInfo: {
...registrationInfo,
credentialPublicKey: Unit8ToString(
registrationInfo?.credentialPublicKey
),
attestationObject: Unit8ToString(registrationInfo?.attestationObject),
rpID: registrationInfo?.rpID || "",
},
};
} catch (error: any) {
console.log("verifyRegisterChallenge Error:: ", error.message);
throw new Error(error.message);
}
};

VerifiedRegistrationResponse type:

export type VerifiedRegistrationResponse = {
verified: boolean;
registrationInfo?: {
fmt: AttestationFormat;
counter: number;
aaguid: string;
credentialID: Base64URLString;
credentialPublicKey: Uint8Array;
credentialType: 'public-key';
attestationObject: Uint8Array;
userVerified: boolean;
credentialDeviceType: CredentialDeviceType;
credentialBackedUp: boolean;
origin: string;
rpID?: string;
authenticatorExtensionResults?: AuthenticationExtensionsAuthenticatorOutputs;
};
};

After successfully verifying, save the user data and credential registration information to the database.

// 4. create user
const newUser = await createUser(session.user);
// 5. save public key credential to database
await createPublicKeyCred(registerInfo, newUser);
...

user Database Schema:

User Schema

public_key_credential Database Schema:

Credential Schema

Finally, after saving the information to the database, we will respond back to the user to confirm the success.

Register Success Response
Register Success Alert

The login (Assertion Phase) will be discussed in part 2 of this article:

--

--