Skip to main content

Configure Passkey Authentication with Amazon Cognito Managed Login

People are bad at remembering passwords. Password managers solve this, but many people don’t use one. Instead, they often reuse the same password across services, sometimes with slight variations. When one service gets breached, every account sharing that password is exposed. MFA would help, but for many users installing Google Authenticator and typing in a six-digit code is too much effort. Passkeys offer a simpler alternative: the credential lives on the user’s device and is unlocked by the same fingerprint or face scan they already use to open their phone. No app to install, no code to copy. With Amazon Cognito’s Managed Login v2, you can enable passkey authentication using a single CloudFormation template.

In this post, you’ll set up passwordless passkey authentication with Amazon Cognito Managed Login v2. You’ll deploy the CloudFormation template and integrate the frontend using browser built-in APIs.

What passkeys replace (and what they don’t)
#

Passkeys are a password alternative, not an MFA alternative. When a user signs in with a passkey, the device performs a local biometric check and sends a cryptographic proof to Cognito. There is no second factor — the passkey is the single factor. It combines something the user has (the device) with something the user is (a fingerprint or face), but because both happen on the same device in a single step, Cognito treats it as one factor, not MFA.

Cognito passkey CloudFormation template
#

The entire Cognito setup fits in one CloudFormation template. The key resource is the User Pool, which needs the ESSENTIALS tier or higher for passkey support:

# cognito-passkey.yaml
UserPool:
  Type: AWS::Cognito::UserPool
  Properties:
    UserPoolTier: ESSENTIALS

    UsernameAttributes:
      - email
    AutoVerifiedAttributes:
      - email

    Policies:
      PasswordPolicy:
        MinimumLength: 8
        RequireUppercase: true
        RequireLowercase: true
        RequireNumbers: true
        RequireSymbols: false
      SignInPolicy:
        AllowedFirstAuthFactors:
          - WEB_AUTHN
          - PASSWORD

    WebAuthnUserVerification: required

    AdminCreateUserConfig:
      AllowAdminCreateUserOnly: true

The WEB_AUTHN value refers to WebAuthn, the browser standard behind passkeys. The SignInPolicy lists both WEB_AUTHN and PASSWORD. You might expect to drop PASSWORD for a passkey-only setup, but Cognito requires WEB_AUTHN to be accompanied by at least one other factor. Alternatives include EMAIL_OTP and SMS_OTP. This template uses PASSWORD because it’s the simplest option and doesn’t require additional messaging infrastructure. Setting WebAuthnUserVerification to required ensures the device always performs a biometric or PIN check during the passkey ceremony.

AllowAdminCreateUserOnly prevents open self-registration. An administrator creates users and guides them toward registering a passkey on first login. This works well for internal tools, enterprise apps, or any context where a passkey registration link is more practical than a temporary password users will immediately forget.

Enabling Managed Login v2 for passkey support
#

Cognito’s Managed Login v2 replaces the old Hosted UI with a modern, customizable login screen. You enable it by setting ManagedLoginVersion: 2 on the domain resource:

# cognito-passkey.yaml
UserPoolDomain:
  Type: AWS::Cognito::UserPoolDomain
  Properties:
    UserPoolId: !Ref UserPool
    Domain: !Ref AWS::StackName
    ManagedLoginVersion: 2

The v2 UI is a significant upgrade over the legacy Hosted UI — modern, customizable, and it introduces passkey support that the old UI never had. You can customize the branding — colors, border radius, layout — through a ManagedLoginBranding resource, but that’s cosmetic. What matters: Cognito handles the login UI, the passkey prompt, and account recovery. You don’t build any of it.

Choosing between passkey and password at sign-in
#

The app client enables the ALLOW_USER_AUTH flow, which is Cognito’s choice-based authentication. At sign-in, users see both passkey and password options — the UI adapts based on what credentials they have registered:

# cognito-passkey.yaml
UserPoolClient:
  Type: AWS::Cognito::UserPoolClient
  Properties:
    UserPoolId: !Ref UserPool
    GenerateSecret: false
    AllowedOAuthFlowsUserPoolClient: true

    ExplicitAuthFlows:
      - ALLOW_USER_AUTH
      - ALLOW_USER_SRP_AUTH
      - ALLOW_REFRESH_TOKEN_AUTH

    AllowedOAuthFlows:
      - code
    AllowedOAuthScopes:
      - openid
      - email
      - profile

GenerateSecret is false because this is a public client — a single-page app running in the browser with no server-side secret. Instead of a client secret, the OAuth flow uses PKCE (Proof Key for Code Exchange) to secure the authorization code exchange. PKCE works in three steps: the client generates a random verifier, sends a hash of it with the authorization request, then sends the original verifier when exchanging the code for tokens. An attacker who intercepts the authorization code can’t use it without the verifier. The approach is defined in RFC 7636.

Securing the token exchange with PKCE
#

The client application is vanilla JavaScript with zero dependencies — no Amplify, no SDK, just the browser’s built-in Web Crypto API. Everything you need for PKCE is already there:

// auth.js
async function generateCodeVerifier() {
    const array = new Uint8Array(32);
    crypto.getRandomValues(array);
    return base64URLEncode(array);
}

async function generateCodeChallenge(verifier) {
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const hash = await crypto.subtle.digest('SHA-256', data);
    return base64URLEncode(new Uint8Array(hash));
}

When a user clicks “Sign In”, the app generates a code verifier and challenge, stores the verifier in sessionStorage, and redirects to Cognito’s /oauth2/authorize endpoint. The Managed Login UI appears — a fingerprint tap if the user has a passkey, or a password if they don’t. Cognito then redirects back with an authorization code. The app exchanges that code for tokens:

// auth.js — CognitoAuth.handleCallback()
const tokenUrl = `https://${this.config.domain}/oauth2/token`;
const params = new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: this.config.clientId,
    code: code,
    redirect_uri: this.config.redirectUri,
    code_verifier: codeVerifier
});

const response = await fetch(tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: params
});

Tokens go into sessionStorage rather than localStorage. localStorage persists indefinitely and survives browser restarts, leaving tokens on disk until explicitly deleted. sessionStorage is scoped to the tab and cleared when it closes. Users re-authenticate each session, but no tokens remain on disk between sessions.

Deploying the stack and creating passwordless users
#

With the template ready, deployment is two commands:

aws cloudformation deploy \
  --template-file cognito-passkey.yaml \
  --stack-name my-passkey-app \
  --parameter-overrides \
    CallbackURL=http://localhost:3000/callback.html \
    LogoutURL=http://localhost:3000/

./update-config.sh my-passkey-app

The update-config.sh script reads the stack outputs and generates a config.js file with the Cognito domain, client ID, and redirect URIs.

To create users, go to the Cognito console, select your User Pool, and create a user with their email address. Cognito sends them a temporary password by email. On first login, they set a new password and can then register a passkey through the app’s “Add Passkey” button. From that point on, they sign in with a fingerprint or face scan instead of typing a password. Passkeys are supported alongside passwords, not enforced — users choose what works for them.

Passwords still exist in this setup
#

Passkeys don’t eliminate passwords entirely. Cognito requires a companion auth factor alongside WEB_AUTHN, and in this setup that’s PASSWORD. Users who were given a temporary password or set one themselves still have that credential on their account. If that password is reused across services and one of those services gets breached, the account is exposed. There is no second factor in this setup to stop an attacker who has the password. Enforce strong, unique password policies and consider adding MFA for workloads with higher security requirements.

Even so, for tools without a need for MFA enforcement, passkeys are still a clear improvement. They effectively push users toward a credential manager — the device’s built-in passkey store — which is a better starting point than no password manager at all.

Passkeys as a practical default
#

Cognito’s Managed Login v2 makes passkey authentication practical without building WebAuthn flows from scratch. A single CloudFormation template provisions the User Pool, domain, app client, and branding. The client side is a standard OAuth PKCE flow using the browser’s built-in Web Crypto API — no SDKs or libraries required.

The real value of passkeys isn’t that they’re more secure than a strong, unique password with MFA — it’s still a single factor. The value is that they remove friction for users who would otherwise take shortcuts. Someone who would otherwise reuse “Summer2025!” across every service now authenticates with a fingerprint stored in their device’s secure enclave. That’s a meaningful step up in practice. For many workloads, getting every user onto a credential manager by default matters more than adding a second factor that half of them will never set up.

View the full source code on GitHub

References
#