AWS Cognito + Passkey Flex

This guide will walk you through adding Passkey Flex passkey support to your app's Amazon Cognito user pool (opens in a new tab) authentication flow. This setup leverages Cognito's native integration with Lambdas using its custom auth challenge triggers (opens in a new tab).

Your final workflow will look like this:

  1. Existing and new users sign up with your app through a Cognito user pool.
  2. Users can sign in and edit their security settings to add a passkey to their account.
  3. Users can sign in using a passkey instead of a password.

Prerequisites

Create your Amazon Cognito User Pool and App Client

To simplify configuration, this guide uses the default values for all steps in the User Pool guided setup except for the following:

  • In step 2, choose Optional MFA under "MFA enforcement" and Authenticator Apps under "MFA methods", so you don't have to setup SMS options later.
Cognito User Pool Multi-factor authentication settingsStep 2 of the AWS Cognito User Pool guided setup
  • In step 4, choose Send email with Cognito under "Email provider", so you don't have to setup SES options.
Cognito User Pool Email settingsStep 4 of the AWS Cognito User Pool guided setup

Here are the full settings used to create the User Pool and App Client.

Cognito User Pool settingsStep 6 of the AWS Cognito User Pool guided setup

Create a Passkey Flex app

If you already have a Passkey Flex app and API key, skip to the next section.

Otherwise, sign up for a free Passage account (opens in a new tab), which should take you to the Passage Console upon completion.

  1. Select Create New App under the corresponding Organization, then select Passkey Flex.
  2. For the domain, use http://localhost:8081. It should match the port of the example web app below.
  3. Once the Flex app is created, take note of the Application ID for later.
  4. Create an API key at Settings > Create API key. Copy this value to use later when you set up the Lambdas and example server and app.

Compile Lambda code

Clone the example-cognito (opens in a new tab) repo to bootstrap your Cognito application. It contains the Lambda code as well as a web app to test it.

Compile the TypeScript Lambda code and dependencies into three zipped JavaScript artifacts to use in the next step:

npm install
npm run build:lambdas

You should now see the three .zip files in the root of the repo:

.
├── LICENSE
├── README.md
├── app
├── create-auth-challenge.zip <--
├── define-auth-challenge.zip <--
├── lambdas
├── node_modules
├── package-lock.json
├── package.json
├── server
└── verify-auth-challenge.zip <--

Add Cognito custom authentication Lambda triggers

You can read more about the Define (opens in a new tab), Create (opens in a new tab), and Verify (opens in a new tab) authentication challenge triggers in AWS's documentation. They add a fork in the authentication process to allow for additional verification steps before issuing or denying a Cognito user pool JWT.

Passage takes advantage of those hooks by creating a transaction for the passkey authentication ceremony in the Create trigger and verifying the response from the end user in the Verify trigger.

The Passage code to start a transaction (opens in a new tab) in the Create trigger

// lambdas/create-auth-challenge/index.ts
const passage = new PassageFlex(passageConfig);
 
const externalId = event.request.userAttributes.sub;
const passageTransactionId = await passage.createAuthenticateTransaction({ externalId });
 
event.response.publicChallengeParameters = { passageTransactionId };

The Passage code to verify (opens in a new tab) the sign in response in the Verify trigger:

// lambdas/verify-auth-challenge/index.ts
const nonce = event.request.challengeAnswer;
const passage = new PassageFlex(passageConfig);
 
await passage.verifyNonce(nonce);
 
event.response.answerCorrect = true;

Now, create the Lambdas and upload the .zip files from the previous step:

Create the Define Auth Challenge Lambda

From the AWS Lambda page, use the following properties to create a new Lambda.

  • Author from scratch
  • Runtime: Node.js 20.x
  • Architecture: x86_64
  • Execution role: Create a new role with basic Lambda permissions

Then upload the define-auth-challenge.zip file for the Define Auth Challenge Lambda code.

Lambda settingsLambda settings for the Cognito custom authentication trigger

Create the Create Auth Challenge Lambda

Using the same process from step 1, create a new Lambda function. Upload the create-auth-challenge.zip file.

Create the Verify Auth Challenge Lambda

Using the same process from step 1, create a new Lambda function. Upload the verify-auth-challenge.zip file.

Set Passage credentials

You need to add Passage credentials to the Create Auth Challenge and Verify Auth Challenge Lambdas since that is where Passage is starting and finishing the authentication process, as described above.

Set the Passage credentials (PASSAGE_APP_ID and PASSAGE_API_KEY) as environmental variables using the Application ID and API key from earlier by selecting Configuration > Environment Variables > Edit

Lambda settingsLambda settings for the Cognito custom authentication trigger

Register the Lambdas

Finally, once you've created and configured all three Lamdas, go back to User Pool > User pool properties.

Select Add Lambda trigger, then select Custom authentication.

Starting with the Define auth challenge trigger type, select the corresponding Lambda you created previously, then add the trigger.

Repeat this for the Create auth challenge and Verify auth challenge response trigger types.

Finish the example repo setup

Finish setting up the example server and web app.

This creates a .env file in each of the server and app folders.

npm run dev

Fill out all of the env vars by grabbing their values from Amazon Cognito and Passage.

# server/.env
PORT=8080
 
AWS_COGNITO_REGION=
AWS_COGNITO_USER_POOL_ID=
AWS_COGNITO_APP_CLIENT_ID=
 
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
 
PASSAGE_APP_ID=
PASSAGE_API_KEY=
# app/.env
VITE_SERVER_PORT=8080
VITE_APP_PORT=8081
 
VITE_AWS_COGNITO_REGION=
VITE_AWS_COGNITO_USER_POOL_ID=
VITE_AWS_COGNITO_APP_CLIENT_ID=
 
VITE_PASSAGE_APP_ID=

Now just start the example server and app.

npm start

Test the flow

First, test the traditional user registration and password login flow, then try adding a passkey for the user and logging in without a password.

Sign up a Cognito user

  1. Sign up a new user with any email (can be fake).
  2. Sign in with your email and password.
  3. Once signed in, you'll see the details of the access token returned to you by Cognito.
Access token detailsDetails of the Cognito access token

Add a passkey for the Cognito user

To add a passkey, there's a single endpoint (opens in a new tab) on the server to start a Passage transaction where the externalId is a unique identifier of the Cognito user:

// server/src/index.ts
const transactionId = await passage.createRegisterTransaction({
    externalId: sub,
    passkeyDisplayName: email,
});
 
return res.json({ transactionId });

There's also a call (opens in a new tab) to that endpoint to kick off the passkey creation process in the app:

// app/src/components/AddPasskeyButton.tsx
const response = await fetch(`http://localhost:${import.meta.env.VITE_SERVER_PORT}/users/add-passkey`, {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${accessToken}`,
    },
});
 
if (!response.ok) {
    throw new Error('Error adding passkey');
}
 
const { transactionId } = await response.json();
await Passage.passkey.register(transactionId);

With your signed in Cognito session, select Add a passkey to start the passkey creation process. After you successfully save your passkey, select Sign out.

Sign in with the passkey

To sign in with a passkey, the app makes two calls (opens in a new tab) to Cognito that initiate and verify the authentication request:

// app/src/cognito.ts
export const signInWithPasskey = async (email: string): Promise<AuthResult> => {
    const initAuthResult = await cognitoClient.send(
        new InitiateAuthCommand({
            ClientId: import.meta.env.VITE_AWS_COGNITO_APP_CLIENT_ID,
            AuthFlow: AuthFlowType.CUSTOM_AUTH,
            AuthParameters: {
                USERNAME: email,
            },
        }),
    );
 
    if (!initAuthResult.ChallengeParameters?.passageTransactionId) {
        throw new Error('Error starting passkey login');
    }
 
    const transactionId = initAuthResult.ChallengeParameters.passageTransactionId;
    const nonce = await Passage.passkey.authenticate({ transactionId });
 
    const authChallengeResult = await cognitoClient.send(
        new RespondToAuthChallengeCommand({
            ClientId: import.meta.env.VITE_AWS_COGNITO_APP_CLIENT_ID,
            Session: initAuthResult.Session,
            ChallengeName: ChallengeNameType.CUSTOM_CHALLENGE,
            ChallengeResponses: {
                USERNAME: email,
                ANSWER: nonce,
            },
        }),
    );
};

Sign in with your passkey by selecting the "Try signing in with a passkey" link under the form.

Notice you still received a Cognito access token, even when signing in with a passkey from Passage (the sub, iss, and scope are the same). You can also see the record of the authentication attempts in Passkey Console in the Events tab.

Passage user eventsPassage user authentication attempts

You've now successfully integrated Passkey Flex into an AWS Cognito stack.

With just three short Lambdas, two calls from the app, and one endpoint you can now have your users sign in with passkeys in your existing Cognito app.