Skip to content

Repository files navigation

Cognito passkey MFA

TypeScript License: MIT AWS CDK

WebAuthn passkeys as a phishing-resistant alternative to TOTP in AWS Cognito's MFA flow.

This is not a passwordless implementation. It replaces the OTP second factor with a device-bound passkey, keeping the existing username + password first factor intact.

Why replace OTP, not passwords?

TOTP codes can be intercepted in real-time by adversary-in-the-middle proxies (Evilginx, Modlishka). The attacker's proxy forwards the code to the real service before the 30-second window expires, capturing the authenticated session cookie.

Passkeys (WebAuthn/FIDO2) are origin-bound: the authenticator cryptographically ties each signature to the exact domain. A phishing proxy on a different origin cannot relay the assertion. This isn't a policy you can bypass (it's math).

By replacing OTP with passkeys at the second factor step, you get the biggest security upgrade (phishing-resistant MFA) without the biggest migration headache (removing passwords).

┌──────────────┐
│  Login Flow  │
├──────────────┤
│              │
│  1. Username │     Unchanged. Familiar.
│  + Password  │     No forced migration.
│              │
├──────────────┤
│              │
│  2. Second   │     ┌─────────────────┐
│     Factor   │────▶│ Passkey (new)   │  Phishing-resistant
│              │     │ OR              │  User's choice
│              │     │ TOTP (existing) │  Graceful fallback
│              │     └─────────────────┘
│              │
└──────────────┘

Architecture

Browser ──▶ Cognito Custom Auth Flow
            ├── DefineAuthChallenge   (orchestrates SRP → custom challenge → tokens)
            ├── CreateAuthChallenge   (generates WebAuthn options + TOTP fallback)
            └── VerifyAuthChallenge   (validates assertion OR TOTP code)

Browser ──▶ API Gateway (HTTP API, JWT-authorized)
            └── PasskeyRegister       (registration options, verify, list, delete)
                    │
                    ▼
               DynamoDB (credentials + challenges with TTL)

Tech Stack

Component Technology
Runtime Node.js 20 (Lambda)
Language TypeScript 5.x (strict)
WebAuthn @simplewebauthn/server v11
Infrastructure AWS CDK v2
Database DynamoDB (single-table, PAY_PER_REQUEST)
API API Gateway v2 (HTTP API)
Auth Cognito User Pool (SRP + custom auth)

Quick Start

Prerequisites

  • Node.js 20+
  • AWS CLI configured with appropriate permissions
  • AWS CDK CLI (npm install -g aws-cdk)

Deploy

# Clone and install
git clone https://github.com/biscolab/cognito-passkey-mfa.git
cd cognito-passkey-mfa
npm install

# Configure (edit these values)
export RP_ID="auth.yourdomain.com"
export RP_NAME="Your Application"
export RP_ORIGIN="https://auth.yourdomain.com"

# Deploy
npx cdk deploy \
  --context rpId=$RP_ID \
  --context rpName="$RP_NAME" \
  --context rpOrigin=$RP_ORIGIN

Client Integration

import {
  authenticateWithPasskey,
  authenticateWithTotp,
} from "./passkey-client";

// During Cognito custom auth challenge step:
const payload = JSON.parse(challengeParameters.challengePayload);

if (payload.webauthn && isPasskeySupported()) {
  try {
    const response = await authenticateWithPasskey(payload);
    await confirmSignIn({ challengeResponse: JSON.stringify(response) });
  } catch {
    // Fall back to TOTP
    const code = getUserInput();
    const response = authenticateWithTotp(code);
    await confirmSignIn({ challengeResponse: JSON.stringify(response) });
  }
}

See src/client/passkey-client.ts for the full reference implementation with registration, credential management, and Amplify v6 integration example.

API Endpoints

All endpoints require a valid Cognito JWT in the Authorization header.

Method Path Description
POST /passkey/register/options Generate WebAuthn registration options
POST /passkey/register/verify Verify and store a new passkey credential
GET /passkey/credentials List user's registered passkeys
DELETE /passkey/credentials/{id} Remove a passkey

Security Considerations

  • Challenge TTL: 5 minutes (configurable), stored in DynamoDB with TTL auto-deletion
  • Signature counter: validated on every assertion to detect cloned authenticators
  • Origin binding: WebAuthn assertions are cryptographically tied to RP_ORIGIN
  • User verification: required on both registration and authentication
  • CORS: restricted to RP_ORIGIN only
  • No credential data exposed: the list endpoint returns metadata only (no public keys or counters)
  • Max 10 passkeys per user: prevents abuse of credential storage

Project Structure

src/
├── types/index.ts                        # Shared TypeScript interfaces
├── lib/
│   ├── config.ts                         # Environment config with validation
│   ├── logger.ts                         # Structured JSON logger
│   ├── dynamo-credential-store.ts        # DynamoDB CRUD operations
│   └── webauthn-service.ts               # WebAuthn registration + assertion
├── lambdas/
│   ├── define-auth-challenge/index.ts    # Cognito trigger: flow orchestration
│   ├── create-auth-challenge/index.ts    # Cognito trigger: challenge generation
│   ├── verify-auth-challenge/index.ts    # Cognito trigger: assertion/TOTP verification
│   └── passkey-register/index.ts         # API: credential registration + management
└── client/
    └── passkey-client.ts                 # Browser-side reference implementation

infrastructure/
└── stack.ts                              # CDK stack (full infrastructure)

Development

npm run typecheck    # TypeScript strict mode check
npm run lint         # ESLint
npm test             # Vitest
npm run test:watch   # Vitest in watch mode

Related

This project was built to support a specific architectural choice: replacing OTP (not passwords) with passkeys in an enterprise MFA flow. The reasoning is explained in detail in this LinkedIn post.

Author

Roberto Belotti

License

MIT

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages