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.
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
│ │ └─────────────────┘
│ │
└──────────────┘
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)
| 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) |
- Node.js 20+
- AWS CLI configured with appropriate permissions
- AWS CDK CLI (
npm install -g aws-cdk)
# 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_ORIGINimport {
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.
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 |
- 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_ORIGINonly - 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
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)
npm run typecheck # TypeScript strict mode check
npm run lint # ESLint
npm test # Vitest
npm run test:watch # Vitest in watch modeThis 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.
Roberto Belotti