Passkeys are replacing passwords. Cognito’s Hosted UI supports them out of the box. But Cognito also supports building a custom login page. I spent a weekend figuring out how to bring that same passkey experience to a custom login page.
In this blog post, we will understand how passkeys work and how to create a custom passkey-supported login page using React and AWS Cognito. Along the way, we will also explore how things work under the hood.
What are Passkeys?
Passwords are both difficult to remember and easy to lose. Passkeys bring a passwordless authentication paradigm based on public-key cryptography.
You can think of a website’s login screen as a lock. Passkeys allow your device (or pass manager) to become the key for that lock.
But… but… but…
The interesting part is that the device can prove it has the key without ever handing the key over.
During registration, a public-private key pair is created by your device (or passkey manager). The private key is kept protected on your device, and the public key is passed to the website.
At the time of login, the website sends a fresh challenge to your device. After you verify yourself, your device uses the private key to sign the challenge. The website then uses the public key to verify the signature. If the signature is valid, you’re logged in.
ie. Challenge → Sign with private key → Signature → Verify with public key → ✅
When we use Cognito's Hosted UI, much of this flow is handled for us. But when we build our own login page, we need to connect our UI to the browser's passkey APIs and Cognito ourselves. And that's what we are going to do in the next step!
Setting up Amazon Cognito for Passkeys
First of all, let us create a Cognito user pool with a public client. Then make the necessary changes to enable passkeys. If you are stuck at any step, please refer to the video for more details.
Step1: Go to client settings and make sure Choice-based sign-in: ALLOW_USER_AUTH is enabled. It makes sure entity logging in can select the sign-in method.
Step2: Go to User pool >> Authentication >>Authentication methods >> Passkey and modify the relying party ID. This should be the same as the domain of the login page. For me, it is “localhost” but if you are using a domain name, it will be like “example.com”.
Step3: Navigate to User pool >> Authentication >> Sign-in >> Options for choice-based sign-in. Then enable passkeys as an available sign-in choice.
Also, make sure MFA is either optional or disabled. And you are good to move on to the next section.
Building the Custom Login Page
Frontend code is available here: https://github.com/TrickSumo/cognito-custom-login-passkey-support
Make sure to create a .env file with the details of the user pool created in the last step. Then install dependencies and run the code.
npm i
npm run dev
The frontend uses @aws-sdk/client-cognito-identity-provider for Cognito API calls and @simplewebauthn/browser for handling the WebAuthn browser API calls for passkey registration and authentication.
Registering a Passkey
Create a Cognito user with the help of the signup form. After successful login, register a passkey using the “Add a passkey” button. The browser will prompt you to save the passkey on the device.
This registration process involves two API calls (you can inspect them in the network tab).
The first call is made to Cognito with the header “x-amz-target: AWSCognitoIdentityProviderService.StartWebAuthnRegistration” and it initiates registration of a passkey. If you notice payload, it contains the access token that you received after successful login.
The response of the first call contains the random challenge and other details:
Then the browser’s navigator.credentials.create() API is called to generate a new key pair on the device. The private key is stored securely on the device and never leaves it. The device then uses that private key to sign the challenge received from the first Cognito API call, proving a real authenticator generated this response. The new public key and the signed proof are then packaged together for the next step.
Then the second API call goes to Cognito with the header “x-amz-target:AWSCognitoIdentityProviderService.CompleteWebAuthnRegistration” and attestationObject/publicKey as the payload. This attestationObject is an encoded value that contains the public key, metadata about the authenticator, and the signature (computed over the clientDataJSON, which itself repeats the original challenge along with the page’s origin).
clientDataJSON after decoding from base64:
If all is good, the response from the second Cognito API is… Guess what? Empty JSON object {}.
Signing in with a Passkey
Logging in with a passkey also involves two API calls to Cognito.
The first one is an initiate auth API call with “PREFERRED_CHALLENGE: WEB_AUTHN”. It returns the challenge:

Then the browser navigator.credentials.get() API is called to produce a signed assertion using the private key.
After that, the second API call to Cognito happens with the RespondToAuthChallenge header and signature as payload. Cognito verifies the payload using the public key that we stored during passkey registration. After successful login, accessToken, idToken, and refreshToken are issued.
Managing Passkeys
The same user can have multiple passkeys, and the good news is that Cognito makes it easy to manage passkeys.
You can call Cognito with the ListWebAuthnCredentials and DeleteWebAuthnCredential header/command to list and delete existing passkeys.
These functionalities are already available in the demo code (using the Cognito SDK). Feel free to explore!
Gotchas!
Make sure passkeys are enabled in the Cognito user pool. Also, the Relying Party ID must match your app’s domain.
If MFA is set to “Required” on the user pool, passkeys quietly stop showing up as a sign-in option even if everything else is configured correctly. Keep MFA optional or off if you want passkeys to work.
A passkey can only be registered by a user who’s already signed in ie. signup can’t create one directly
Conclusion
In our implementation, we used Cognito APIs together with the browser’s WebAuthn APIs to register and authenticate users with passkeys.
The nice part is that we don’t have to implement the cryptography ourselves. The browser and passkey provider handle the sensitive operations, while Cognito handles authentication and token issuance.
Hope you enjoyed the tutorial 😊








Top comments (0)