Passkey Authentication with Amazon Cognito & LoginID
This guide demonstrates how to implement Passkey Authentication using LoginID and Amazon Cognito custom authentication.
In this guide you will learn how to:
- Enable LoginID Passkeys for a new or existing Cognito User Pool
- Allow new and existing users to register a passkey
- Authenticate using a passkey (using Autofill UI)
- Authenticate using native Cognito authentication (OTP or password)
Prerequisites
- AWS Account with access to:
- Cognito
- Lambda
- S3
- Secrets Manager
- CloudFormation
- SES
- A workstation or cloud host with AWS CLI installed
- A LoginID Account
Overview
Amazon Cognito is an identity platform for web and mobile apps that provides a user directory, an authentication server, and an authorization service for OAuth 2.0 access tokens. Amazon Cognito can be configured to support username/password or passwordless OTP authentication.
In this guide we’ll use custom authentication flows to set up a challenge/response-based authentication model, and then extend the model using AWS Lambda functions to call LoginID APIs to enroll and authenticate users using Passkeys.
A high level workflow is illustrated below:
The User Experience
At the end of this guide you will integrate LoginID into your own application that will initiate authentication using Amazon Cognito. A new user will be able to signup and a returning user will be able to authenticate and create a new passkey.
Implementation Guide
In this guide we’ll walk through each of the steps to enable passkeys for Amazon Cognito:
- Create an Amazon Cognito user pool (optional)
- Create a LoginID Account & setup your application
- Deploy Cognito Custom Authentication Lambdas
- Integrating the LoginID SDK into your application
Environment Setup
In this guide we’re going to deploy a Cognito user pool and enable custom authentication using AWS Lambda functions. To deploy these functions we’ll use AWS S3 storage to stage their source code, and we’ll use AWS Secrets Manager to store our LoginID credentials so we don’t have to hard-code them into our Lambda functions.
Step 1: (Optional) Create an Amazon Cognito User Pool
Amazon Cognito is built on open identity standards, and provides an identity store to authenticate your application’s users. LoginID will later integrate with this user pool to allow your users to enroll in Passkeys using our Cognito SDK.
In this step, the following resources will be created:
- Cognito User Pool
- Cognito Client
This step can be skipped if you already have a Cognito user pool, or you can use it to create a test user pool.
Creating Cognito User Pool and Client
Run the Cloudformation template with the following command:
aws cloudformation create-stack \
--stack-name $STACK_NAME \
--template-body file://CognitoPoolOnly.yaml \
--capabilities CAPABILITY_AUTO_EXPAND CAPABILITY_NAMED_IAM
aws cloudformation wait stack-create-complete --stack-name $STACK_NAME
aws cloudformation describe-stacks --stack-name $STACK_NAME
Once the CloudFormation template completes, you will receive:
- Cognito User Pool ID
- Cogntio User Pool ARN
- Cognito Client ID
The Cognito user pool defined within our template takes advantage of the PreSignup Lambda trigger. This presignup function automatically verifies the user and validates their email address at signup. This allows new users to obtain an access token on their first authentication without the need for administrator approval and verification.
Step 2: Create a LoginID Account and Application
In this step we are going to configure LoginID and all the necessary credentials to make the integration to work with your Cognito user pool.
This step will produce the following:
- LoginID account
- LoginID application
- Application API Key
Setup only requires creating a LoginID developer account and then creating a LoginID integration app.
Create Account with LoginID
Ensure that you are signed in to your LoginID developer account. You can click on the Register
link to create an account.
Create Application with Cognito Wizard
Follow and complete the Cognito Wizard setup.
Create a new application to obtain a base URL
and API Key
.
- Select
AWS Cognito
for technology to integrate - Enter
Website URL
input field which is the base URL of your website - Enter
Cognito User Pool ID
if you have existing Cognito pool setup - you may enter this later in order to use the management functionalities of the SDK
Here is an example of a local developer setup where your application is hosted on http://localhost:3000
.
You do not need to enter your Cognito user pool ID when prompted. It can be entered anytime after setup is completed.
Once you are on your application settings copy the following:
- Base URL
- API Key
API key will be shown only once after creation, so make sure to save it. In the next steps, we'll secure it using Secrets Manager.
Step 3: Deploy Cognito Custom Authentication Lambdas
This is for clients with Cognito user pool without any custom authentication Lambda triggers in place. However, if you have existing custom lambdas functions please contact us at support@loginid.io to get in touch with our integration engineer.
LoginID provide a quick and simple step to enable passkey authentication to your Amazon Cognito using AWS cloud formation. This step will deploy the following Cognito Custom Authentication Lambdas(nodejs) for handling passkey creation and authentication to your Cognito apllication.
- DefineAuthChallenge Lambda
- CreateAuthChallenge Lambda
- VerifyAuthChallenge Lambda
You can download the CloudFormation script from the following link AWS CloudFormation script.
Once you have downloaded the AWS CloudFormation template, you can deploy the template by running the following AWS cli.
STACK_NAME=<stack-name>
BASEURL=<loginid baseurl>
APIKEY=<loginid application apikey>
COGNITO_ID=<cognito pool id>
COGNITO_CLIENT_ID=<cognito client id>
S3Bucket=cognito-loginid
S3Directory=lambdas/nodejs/passkey-only
aws cloudformation create-stack \
--stack-name $STACK_NAME \
--template-body file://path/to/template/TemplateExisting.yaml \
--parameters \
ParameterKey="S3Bucket",ParameterValue="${S3Bucket}" \
ParameterKey="S3Directory",ParameterValue="${S3Directory}" \
ParameterKey="LOGINIDBaseURL",ParameterValue="${BASEURL}" \
ParameterKey="LOGINIDAPIKeyID",ParameterValue="${APIKEY}" \
ParameterKey="CognitoUserPoolID",ParameterValue="${COGNITO_ID}" \
ParameterKey="CognitoClientID",ParameterValue="${COGNITO_CLIENT_ID}" \
--capabilities CAPABILITY_AUTO_EXPAND CAPABILITY_NAMED_IAM
aws cloudformation wait stack-create-complete --stack-name $STACK_NAME
aws cloudformation describe-stacks --stack-name $STACK_NAME
Below is an explanation of each parameter used in the above command:
- STACK_NAME: The name you choose for the CloudFormation stack.
- BASEURL: The base URL obtained from the LoginID dashboard.
- APIKEY: Your LoginID application’s API key, obtained from the LoginID dashboard.
- COGNITO_ID: The ID of the Cognito user pool.
- COGNITO_CLIENT_ID: The Client ID for the Cognito app.
- S3Bucket: Fixed to cognito-loginid, the S3 bucket where the Lambda resources are stored and fetched.
- S3Directory: Directory within the S3 bucket from where to fetch the Lambda functions.
Step 4: Integrating the LoginID SDK into your application
The SDK offers three primary functionalities: signup
, signin
, and management
.
These functions integrate seamlessly to build your client authentication flows. The subsequent sections will show case how to implement common user scenarios with the SDK.
Installing the SDK
To complete the setup, install the front-end WebSDK, which facilitates passkeys and custom authentication. This SDK leverages the amazon-cognito-identity-js library for Cognito operations. Install the SDK using the following command:
npm i @loginid/cognito-web-sdk
To initialize the SDK, we need to configure LoginIDCognitoWebSDK
with Cognito pool ID
, Cognito client ID
, and LoginID application baseURL
from prior steps.
import { LoginIDCognitoWebSDK } from "@loginid/cognito-web-sdk";
// config parameters from env variables
const cognitoPoolId = process.env.NEXT_PUBLIC_COGNITO_POOL_ID || "";
const cognitoClientId = process.env.NEXT_PUBLIC_COGNITO_CLIENT_ID || "";
const loginidBaseUrl = process.env.NEXT_PUBLIC_LOGINID_BASE_URL || "";
export class LoginidService {
static client = new LoginIDCognitoWebSDK(
cognitoPoolId,
cognitoClientId,
loginidBaseUrl
);
}
User Sign Up Flows
Scenario Sign Up With Email and Password and Create Passkey
This flow allow user create a passkey after signing up for an account with username and password.
- Enter email as
username
andpassword
to sign up. - Prompt the user to create a passkey.
When the user arrives at the sign-up page, they will enter their username and password to register a Cognito user in the user pool. The SDK supports password registration with signUpWithPassword
, but you can also use your existing implementation if you have one.
import { LoginidService } from "@/services/loginid";
//...
//...
export default function SignupPrompt(props: SignupPromptProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const handlerSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
// signup user with password
const autoSignIn=true;
const user = await LoginidService.client.signUpWithPassword(
email,
password,
setConfirmPassword,
autoSignIn
);
props.onComplete(true);
} catch (e: any) {
console.log(e);
}
};
return (
// react UI components
//..
//..
)
}
In this example, the fourth parameter is set to true
, allowing the user to automatically sign in with the given password after sign up. This only works if user auto-confirmation is supported on your Cognito user pool. If not, you will need to use your own user confirmation method and set the flag to false
.
Immediately after signing up, the user is considered authenticated and has received a Cognito token. At this point, the user can add a passkey. This can be done by presenting a prompt to add a passkey or by providing the option somewhere else such as the user's profile or security settings. Use the createPasskey
method for this.
import { LoginidService } from "@/services/loginid";
//...
//...
export function AddPasskey(props: AddPasskeyProps) {
const handlerSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
//add passkey to authenticated cognito user
const token = LoginidService.client.getSessionInfo().idToken;
const session = await LoginidService.client.createPasskey(props.email,token);
} catch (e: any) {
console.log(e);
}
};
return (
// react UI components
//..
//..
)
}
The user will now have both password and passkey credentials.
User Sign In Flows
Scenario Sign-In with Passkey Auto-fill and Passkey with Password Fallback
Here user can sign-in with the following SDK functions:
- User trigger authentication with Passkey autofill (1) using
authenticateWithPasskeyAutofill
- User trigger authentication with passkey (2) and fallback (3) using
authenticateWithPasskey
For authenticationPasskeyAutofill
, user can sign in by clicking on the username field and a list of available passkey(s) will be presented. This provide a quick single-click login experience for those with biometric capable devices.
For authenticateWithPasskey
, LoginID will check if a passkey is available on the device. If passkey is detected user will then be prompted with passkey login flow. Otherwise, we can default to an fallback result which is a password authentication flow.
Here's an example combining both passkey autofill sign-in and passkey with password fallback sign-in.
First, make sure to include the autocomplete attribute to the username input field with the value "username webauthn".
<form>
<label for="username">Email:</label>
<input
type="email"
id="username"
name="username"
autocomplete="username webauthn"
required
/>
<button type="submit">Submit</button>
</form>
Second, add logics to handle passkey auto fill authenticateWithPasskeyAutoFill and passkey with fallback authenticateWithPasskey
import { useEffect, useState } from "react";
import { LoginidService } from "@/services/loginid";
//...
//...
export interface LoginProps {
onComplete: (email: string, session: CognitoUserSession | null, fallback?: string) => void;
}
const LoginPrompt = function (props: LoginProps) {
const [abortController] = useState(new AbortController());
const [email, setEmail] = useState("");
// On page load or component load
useEffect(() => {
const passkeyAutofill = async () => {
try {
const options = { abortController: abortController };
const session = await LoginidService.client.authenticateWithPasskeyAutofill(options);
props.onComplete(loginid.getCurrentUsername(), session);
} catch (e: any) {
// You can ignore if AbortError is present
// It will mean user has left the page
if (error.name === "AbortError") return;
console.log(e);
}
};
passkeyAutofill();
return () => {
abortController.abort();
};
});
const handlerSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const result = await LoginidService.client.authenticateWithPasskey(email, {abortController});
if(result.isAuthenticated){
// complete authentication
} else {
// perform fallback
return props.onComplete(username, null, "password");
}
} catch (e: any) {
console.log(e);
}
};
return (
// react UI components
//..
//..
)
};
In this example, when passkey is not available on device we redirect the user to the password component, where they will now use their password to sign in as a fallback.
import { LoginidService } from "@/services/loginid";
//...
//...
export default function SigninPrompt(props: SigninPromptProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handlerSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
// signup user with password
const user = await LoginidService.client.authenticateWithPassword(
email,
password,
);
props.onComplete(true);
} catch (e: any) {
console.log(e);
}
};
return (
// react UI components
//..
//..
)
}
Scenario: Sign In with Password then Upgrade Passkey
This is a typical and recommended flow for adding passkeys to an existing Cognito user pool. The steps are:
- User sign's in with
username
andpassword
. - Prompt the user to optionally add a passkey.
When the user arrives at the sign-in page, they will enter their username and password to authenticate a Cognito user in the user pool. The SDK supports password authentication with authenticateWithPassword
, but you can also use your existing implementation if you have one.
import { LoginidService } from "@/services/loginid";
//...
//...
export default function SigninPrompt(props: SigninPromptProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handlerSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
// signup user with password
const user = await LoginidService.client.authenticateWithPassword(
email,
password,
);
props.onComplete(true);
} catch (e: any) {
console.log(e);
}
};
return (
// react UI components
//..
//..
)
}
Immediately after signing in, the user is considered authenticated and has received a Cognito token. At this point, the user can add a passkey. This can be done by presenting a prompt to add a passkey or by providing the option somewhere else such as the user's profile or security settings. Use the createPasskey
method for this.
import { LoginidService } from "@/services/loginid";
//...
//...
export function AddPasskey(props: AddPasskeyProps) {
const handlerSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
//add passkey to authenticated cognito user
const token = LoginidService.client.getSessionInfo().idToken;
const session = await LoginidService.client.createPasskey(props.email, token);
} catch (e: any) {
console.log(e);
}
};
return (
// react UI components
//..
//..
)
}
The Cognito user will now have a passkey.
Passkey Management
The SDK provides methods for managing Cognito users' passkeys with the following functions: listPasskeys
, renamePasskey
, and deletePasskey
. It is important to note that these methods require the Cognito user to be signed in to work.
Although there are different ways to implement passkey management, here is a basic example that combines all three functions into one component.
The Cognito user needs to be signed in to use these functions. In other words, a Cognito ID token must be present on the client.
import { useEffect, useState } from "react";
import { LoginidService } from "@/services/loginid";
function Passkeys() {
const [passkeyID, setPasskeyID] = useState<string | null>(null);
const [error, setError] = useState("");
const [passkeys, setPasskeys] = useState<PasskeyCollection>([]);
useEffect(() => {
listPasskey();
}, []);
async function listPasskey() {
try {
const collection = await LoginidService.client.listPasskeys();
setPasskeys(collection);
} catch (e) {
console.log(e);
}
}
const handleRename = async (id: string, name: string) => {
try {
await LoginidService.client.renamePasskey(passkeyID, name);
const newData = passkeys.map((passkey) => {
return passkey.id === id ? { ...passkey, name: name } : passkey;
});
setPasskeys(newData);
setError("");
} catch (e) {
setError(e.message);
}
};
const handleDelete = async () => {
try {
await LoginidService.client.deletePasskey(passkeyID);
const newData = passkeys.filter((passkey) => passkey.id !== passkeyID);
setPasskeys(newData);
setError("");
} catch (e: any) {
setError(e.message);
}
};
return (
// react UI components
//..
//..
);
}