Skip to content

Passkeys by Default: The Real Work Is Recovery, Not Login

A Cognito passkey migration guide for SaaS teams on email, password, and SMS OTP: configuration, enrollment, and the four-tier recovery ladder that replaces SMS.

Ayhan Sipahi Ayhan Sipahi

Passkeys have crossed from advanced option to default sign-in method. Amazon Cognito has offered passkey sign-in in managed login (opens in new tab) since November 2024. Microsoft Entra ID starts enabling passkeys by default in September 2026, and the WebAuthn specification behind them reached W3C Recommendation status at Level 3 (opens in new tab) in August 2026. For a team that owns an email, password, and SMS OTP flow, the login ceremony is now the small half of the migration: a feature-plan decision, one configuration block, a few API calls. The large half is what happens when the passkey is unavailable, because hardening login moves attack traffic to account recovery. First the Cognito rollout, then the part that deserves most of the attention: the recovery ladder that replaces SMS.

Why SMS OTP Is Being Retired#

The pressure on the current stack is specific, and it comes from three directions at once.

The first is the standards side. NIST SP 800-63B (opens in new tab), now in its fourth revision, classes out-of-band verification over the public switched telephone network as a restricted authenticator type. Restricted means an organization that keeps SMS or voice codes has to assess the risk, offer users an alternative, and maintain a migration plan. That falls short of a ban; in practice it turns SMS from an unremarkable default into a finding that every audit asks about.

The second is the platform side. Microsoft has published a retirement timeline for Entra ID (opens in new tab), its workforce identity product. Passkeys become the default with automatic enablement from September 1, 2026, and Microsoft-provided SMS and voice delivery ends on February 1, 2027. The path left for segments with a compliance need is a customer-managed telecom provider. The scope matters: this is workforce identity, and consumer products weigh the trade differently. The alternatives section returns to that split.

The third is the attack record. The joint advisory on Scattered Spider (AA23-320A) (opens in new tab) describes intrusions that begin away from the login form: attackers talk a help desk into resetting a password or moving an MFA registration onto a device they control, or they take over the phone number itself through a SIM swap. Either way, they then sign in through the ordinary flow. Nothing in that chain touches password strength or OTP entropy.

Passkey deployments shift the same pressure one step further along. Unit 42 published research (opens in new tab) in August 2026 on three techniques, named Pass-ta-key, Silver Pass-ta-key, and Golden Pass-ta-key, demonstrated against the credential store behind Google Password Manager on Windows. All three assume malware already running as an ordinary user on the victim’s machine. All three leave the FIDO protocol intact and work instead through the credential store’s re-registration and onboarding paths. For a team planning a rollout, that research reads as a map of where the pressure lands next: enrollment, re-registration, and recovery.

Turning Passkeys On in Cognito#

On the Cognito side the login half is short: three prerequisites, then one configuration call.

Passkeys are available on every user pool feature plan (opens in new tab) except Lite. New pools default to Essentials; an existing pool moves by setting UserPoolTier in UpdateUserPool, and a WebAuthn configuration attempt on a Lite pool fails with FeatureUnavailableInTierException. Because the tier switch changes the per-monthly-active-user bill, check current rates on the Cognito pricing page (opens in new tab) before moving a large base. Next, passkeys exist only in the choice-based authentication flow (opens in new tab), so the app client needs ALLOW_USER_AUTH among its allowed flows. Finally the factor joins the pool’s sign-in policy:

"Policies": {
  "SignInPolicy": {
    "AllowedFirstAuthFactors": ["PASSWORD", "WEB_AUTHN", "EMAIL_OTP"]
  }
}

In the console, the Password option is always on and cannot be turned off; the API itself does not require PASSWORD in the list. SMS_OTP is the fourth accepted value, and the default here leaves it out deliberately. WebAuthn behavior itself is set through SetUserPoolMfaConfig:

"WebAuthnConfiguration": {
  "RelyingPartyId": "auth.example.com",
  "UserVerification": "required",
  "FactorConfiguration": "MULTI_FACTOR_WITH_USER_VERIFICATION"
}

Two of these three fields carry the security posture. UserVerification controls whether the authenticator must locally verify its owner, with a fingerprint, face, or PIN, before signing. The default is preferred in both the console and the API; preferred allows registration of authenticators that have no user-verification capability and lets sign-ins succeed without the check. required mandates it. The trade-off: required excludes older security keys that cannot verify a user and adds a prompt on some hardware. In exchange, the passkey can satisfy an MFA requirement, and the UV flag in each assertion becomes a check the pool enforces. The exchange matters more on Cognito than elsewhere, because AWS documents (opens in new tab) that Cognito does not currently support attestation enforcement; attestation is the authenticator’s signed statement about its own model and provenance. Unit 42’s mitigation guidance asks relying parties for exactly one control: require user verification and validate the UV flag on every authentication response. The attestation half of its guidance is addressed to credential managers, not relying parties. On Cognito the UV control is therefore the half of the defense a user pool can enforce, which is a strong argument for taking it.

FactorConfiguration decides what the passkey counts for. Under SINGLE_FACTOR it is one factor; only MULTI_FACTOR_WITH_USER_VERIFICATION lets a user-verified passkey satisfy an MFA requirement on its own. Note the direction: a passkey in Cognito is always a first factor. There is no flow in which a password sign-in is followed by a passkey as the second step.

Sign-in from a custom UI is one request:

{
  "AuthFlow": "USER_AUTH",
  "AuthParameters": {
    "USERNAME": "user@example.com",
    "PREFERRED_CHALLENGE": "WEB_AUTHN"
  },
  "ClientId": "1example23456789"
}

Cognito answers with a WEB_AUTHN challenge whose parameters carry the options for the browser’s navigator.credentials.get() call; the signed result goes back through RespondToAuthChallenge as the CREDENTIAL response. Omitting PREFERRED_CHALLENGE yields SELECT_CHALLENGE with the factors that specific user holds, which is the same list managed login renders as sign-in buttons. Pool fundamentals beyond this (custom flows, federation, multi-tenancy) are covered in the Cognito deep dive, and the WebAuthn vocabulary used here is defined in the security glossary.

Choosing the Relying Party ID#

The relying party is the service a WebAuthn credential is scoped to, and RelyingPartyId names the domain that carries the scope. Cognito defaults it to the custom domain when managed login branding and a custom domain exist, otherwise to the prefix domain. It accepts any domain that is not on the public suffix list. One documented exception applies: a pool with a custom domain that authenticates through managed login or the classic hosted UI must use that custom domain’s fully-qualified name as the relying party ID.

Warning

The relying party ID is effectively immutable once real users exist. AWS states the consequence directly: after a change, users must register their passkeys again under the new value. Decide it before the first credential is created, and decide it at the domain level the product will still occupy years from now, typically the apex or an auth subdomain, where the custom-domain rule leaves the choice open.

Products spread across more than one registrable domain need one extra mechanism, because each passkey binds to exactly one relying party ID. WebAuthn Level 3 standardizes Related Origin Requests (opens in new tab) for this case: the RP ID’s domain publishes an allowlist at /.well-known/webauthn, and browsers then honor the credential on the listed origins. WebAuthn requires clients to support at least five registrable domain labels, and in practice no shipping browser goes above five, so a large portfolio of country domains still needs grouping. Cognito additionally expects to find the association file at the relying party domain for native mobile apps to pass origin checks.

Enrolling the Users You Already Have#

Turning the feature on enrolls nobody. Managed login (opens in new tab) prompts for passkey creation at sign-up only; users who registered before the rollout, and any account created by an administrator, never see the prompt. Cognito also requires at least one completed sign-in before a user may register a passkey at all. Enrollment of an existing base is therefore its own project inside the migration, with two implementation paths.

With managed login, the path is a redirect. Sign the user in with password or email OTP, then send the browser to the hosted registration page at https://<your-domain>/passkeys/add?client_id=<id>&redirect_uri=<encoded-uri>; Cognito serves it only to an authenticated session. The product decision is when to trigger that redirect and how insistently, which the credential-count check below can drive.

With a custom UI, registration is three calls around the browser’s credential manager. This is browser code; the same SDK client runs in the page that hosts the sign-in form:

import {
  CognitoIdentityProviderClient,
  StartWebAuthnRegistrationCommand,
  CompleteWebAuthnRegistrationCommand,
} from "@aws-sdk/client-cognito-identity-provider";

const client = new CognitoIdentityProviderClient({ region: "eu-central-1" });

// Runs in the browser after sign-in. The access token must carry
// the aws.cognito.signin.user.admin scope.
export async function registerPasskey(accessToken: string): Promise<void> {
  const { CredentialCreationOptions } = await client.send(
    new StartWebAuthnRegistrationCommand({ AccessToken: accessToken }),
  );

  // WebAuthn Level 3 JSON helpers; no manual base64url handling needed
  const credential = (await navigator.credentials.create({
    publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(
      CredentialCreationOptions as PublicKeyCredentialCreationOptionsJSON,
    ),
  })) as PublicKeyCredential;

  // HTTP 200 with an empty body means the credential is registered
  await client.send(
    new CompleteWebAuthnRegistrationCommand({
      AccessToken: accessToken,
      Credential: credential.toJSON(),
    }),
  );
}

The management surface for an account-settings page comes from the same API family. ListWebAuthnCredentials (opens in new tab) returns each credential’s ID, friendly name, transports, and creation time, and DeleteWebAuthnCredential removes one; both authorize with the user’s access token and the same scope, and a user can hold up to 20 passkeys. The list call doubles as the rollout’s steering instrument. After any successful sign-in, check the count: zero registered passkeys routes the user toward registration, one routes them toward a second. The next section turns that pattern into policy.

The Recovery Ladder#

Recovery decides whether the migration removes SMS or reintroduces it through a side entrance. The design that holds up is a ladder of four tiers, with the product investment concentrated at the top, because every case resolved early never reaches the expensive tiers below it.

Tier 0: A Second Credential, Registered Early#

The cheapest recovery is the one that never runs. The FIDO Alliance’s account-recovery guidance (opens in new tab) has made the point since 2019: the primary mitigation for a lost authenticator is another registered authenticator. Concretely, ask for a second passkey in the session that created the first, or at the next sign-in. A phone plus a laptop covers most setups; a phone plus a roaming security key covers the rest. Cognito has no setting that requires a second credential, so the check lives in application code against ListWebAuthnCredentials. The cost is one prompt screen, and it is the highest-leverage screen in the entire rollout.

Synced passkeys appear to solve the same problem at the platform level, and to a first approximation they do: a credential synced through a platform account survives the loss of any single device. The trade is concentration. Unit 42’s Golden Pass-ta-key technique targets the security-domain secret that protects every synced passkey in a Google account. The research reports no way to rotate or revoke that secret once it leaks; the precondition remains malware on the user’s machine. The reasonable position for a relying party is to accept synced credentials gladly and still prompt for a second credential under the user’s own control. The second one is the one whose risk you can reason about.

Tier 1: Email OTP as the Self-Service Fallback#

For a user with no second credential and nothing sensitive at stake, self-service keeps the support queue out of it. The fallback to standardize on is EMAIL_OTP, already present in the sign-in policy above. Email OTP is not phishing-resistant; a relay that proxies the sign-in page captures the code like any other code. It is immune to SIM swap and number porting. For most SaaS products the inbox is already the de facto root of the account, because password resets land there today. This tier consolidates recovery onto the one weak root the account depends on anyway; the rest of the ladder exists because consolidation alone does not cover the accounts that matter.

Tier 2: Re-Verification for Sensitive Accounts#

Above a sensitivity line the product defines, sending a code somewhere stops being an acceptable answer, because the code inherits the weakest property of its channel. The stronger answer is to re-run identity proofing: whatever the product verified at signup or has verified since. Depending on the product, that can mean confirming a payment method on file, approving the recovery from a session still signed in on another device, a document check where the domain already requires one, or confirmation through a B2B customer’s own admin. This is the second half of the FIDO recovery model: multiple authenticators to avoid recovery, re-proofing when recovery is unavoidable. The constraint is inventory. Tier 2 exists only if the product has proofing signals to re-run, and building the first one is product work. The inventory question therefore belongs in the migration plan, before the first lockout forces it.

Tier 3: Assisted Recovery as a Privileged Operation#

A human-assisted path exists whether or not anyone designs it; the help-desk pattern in AA23-320A shows what an undesigned one turns into. Designing it means treating an assisted reset with the seriousness of a production deploy: an out-of-band callback to a contact already on file, a mandatory delay window before the reset lands, and notification to every registered device and address. Add separation of duties, so no single agent both verifies identity and executes the reset, and an audit trail across all of it. On the Cognito side the primitives are AdminSetUserPassword and AdminResetUserPassword; everything of value sits in the process around them. The trade-off is explicit: this tier has the highest assurance ceiling, the highest per-case cost, and the most attacker attention. The migration plan should budget it as a process, with the button as the smallest line item.

Building Recovery Codes Yourself#

Cognito ships no recovery-code feature, so one-time codes are a build decision, and the freedom is what makes them easy to get wrong. The rules mirror password storage: hash every code, enforce single use, show plaintext exactly once at generation, store hashes and a remaining count, and rate-limit redemption at least as strictly as ForgotPassword. Printed one-time codes serve a technical audience well at Tier 1; for a broad consumer base, expect a substantial share never to be stored anywhere retrievable. Skipping any of the rules stores what is in effect a second, weaker password.

Routing between the tiers deserves an explicit decision tree rather than conditions accreted over incidents:

Yes

No

No

Yes

Yes

No

User cannot present a passkey

Second registered credential?

Sign in with it and prompt to replace the lost credential

Sensitive account or data?

Tier 1: email OTP, then require a second credential

Re-verification signal available?

Tier 2: re-verify, then bind a new passkey

Tier 3: assisted recovery with callback, delay window and audit trail

SMS appears nowhere on this ladder. It returns only where a written regulatory or operational requirement covers a specific user segment, and the alternatives section below treats that case on its merits.

The Recovery-Channel Collision in Cognito#

One platform-specific interaction earns its own section, because it is the point where correctly configured pools drift back toward SMS.

AccountRecoverySetting (opens in new tab) decides where ForgotPassword codes go. It accepts verified_email, verified_phone_number, or admin_only, each with a priority, and Cognito delivers the code to exactly one destination: the highest-priority one available for that user. Left unspecified, the legacy behavior tries a verified phone number first and uses email only for users without a phone attribute, which is the inverse of what this migration wants. Setting verified_email at priority 1 is therefore part of the rollout, and it collides with MFA configuration in a documented way: a user’s MFA channel cannot double as that user’s recovery channel.

  • A user whose MFA preference is email OTP cannot receive a password-reset code by email.
  • A user whose MFA is SMS cannot receive the code by SMS.
  • With AccountRecoverySetting defined and SMS MFA configured for a user, SMS is excluded from recovery for that user entirely.

A user left without any valid destination gets InvalidParameterException from ForgotPassword, which reaches the user as a forgot-password screen that cannot proceed. AWS’s documented remedies are to add SMS as the second recovery option, or to require both email and phone_number attributes so a non-MFA channel always exists. Read that back against the first section: the platform’s own guidance, applied mechanically, reinstates the channel NIST restricts. The guidance optimizes for the right thing on its own terms, which is users staying unlocked. The migration-consistent resolution treats the collision as a design signal, and it splits by MFA channel. A second contact attribute helps only the users whose MFA is not email: for them, a verified email address gives recovery a clean destination. For users whose MFA is email OTP the exclusion follows the email itself, and an added phone number just routes their codes back to SMS. Move that segment to Tier 2 re-verification and stop sending codes altogether.

One more documented interaction sits nearby. OTP first factors are incompatible with MFA set to required at the pool level, and even under optional MFA, a user who has set an MFA preference cannot sign in through an OTP first factor. A pool that flips MFA to required after enabling email OTP breaks its own fallback. Operationally, ForgotPassword codes stay valid for one hour, and AWS documents between 5 and 20 request-or-entry attempts per user per hour depending on risk signals; a self-built recovery path should match those limits.

Pitfalls Beyond the Collision#

Four more traps come up in reviews of plans like this one.

Leaving UserVerification on preferred. It is the silent default in both console and API. Registration then accepts authenticators that cannot verify their user, sign-ins succeed without verification, and the passkey no longer qualifies under MULTI_FACTOR_WITH_USER_VERIFICATION. The assurance the rollout was meant to buy never arrives, and nothing warns about it.

Shipping with one credential per user. Tier 0 then protects nobody, and every lost or wiped device routes to the most expensive tier. The prompt for a second credential costs one screen; skipping it moves the cost to the support queue for every lost device.

Treating passkey deletion as an ordinary toggle. Removing the last credential is a security event in the same class as a password change, and Unit 42’s findings put re-registration at the center of the new attack surface. Deleting the final passkey deserves re-verification and a plain statement of what sign-in will look like afterwards; a fresh registration that follows a deletion deserves a notification to the account’s other channels.

Starting the rollout on the Lite plan. Nothing WebAuthn-related works there, and the failure shows up as FeatureUnavailableInTierException at configuration time. The plan change belongs on the migration’s critical path and in its cost estimate, ahead of any configuration above.

Instrumenting the Recovery Path#

Once login hardens, recovery carries the interesting traffic, so the instrumentation has to exist before the rollout ships. Most of these are application-level counters around your own recovery endpoints and API calls. The set worth wiring in:

  • Share of active users with two or more registered credentials, straight from ListWebAuthnCredentials. This is the resilience number; enrollment share alone flatters the rollout.
  • Recovery initiations per thousand active users, and completions broken down by channel. The SMS share of completions is the figure the migration exists to drive to zero.
  • InvalidParameterException rate on ForgotPassword. Anything above zero is a population with no valid recovery destination, the dead end the collision section describes.
  • Passkey deletions without a replacement registered within a few days. A sustained value here marks the re-registration surface the attack research points at.
  • Sign-in failure rate before and after each stage of the rollout, as the regression alarm for the login half.

Each number has a decision attached: the two-credential share gates how hard the second-credential prompt pushes, the SMS completion share gates when the last telecom dependency ends, and the exception rate gates whether the contact-attribute requirement can ship.

Two Alternatives Worth Taking Seriously#

Every default above carries an override case. The table collects them, and the two overrides that dominate migration discussions get fuller treatment below.

DecisionDefaultOverride whenWhat the override costs
UserVerificationrequiredThe fleet includes hardware keys without UV capabilityPasskeys stop counting as MFA, and the one Unit 42 mitigation available on Cognito is gone
FactorConfigurationMULTI_FACTOR_WITH_USER_VERIFICATIONA separate MFA step stays under application controlMore flows to build, test, and support
AllowedFirstAuthFactorsPASSWORD, WEB_AUTHN, EMAIL_OTPA regulated segment requires a telecom channelSMS_OTP returns, with restricted-authenticator obligations attached
AccountRecoverySettingverified_email at priority 1Email is that user’s MFA channelEither SMS as the second recovery option, or Tier 2 re-verification for the segment
Feature planEssentialsPasskeys are genuinely out of scopeLite offers no passkey support at all
Credentials per userTwo, prompted by the productKiosk or shared-device populationsDevice loss routes directly to Tier 3

Keeping SMS OTP#

The strongest case for SMS is that it already works: it is deployed, users understand it, and it survives an email-account compromise, which email OTP does not. Google’s consumer guidance sits on this side; its passkey user-journey documentation (opens in new tab) recommends keeping a fallback channel so a user who deletes every credential still has a way back in. For a consumer product at scale, lockout is the dominant failure mode, and Google’s advice weighs it accordingly. Microsoft weighs the other side, because in workforce identity the dominant failure is compromise, and it is removing its own SMS delivery outright. A B2B SaaS with an email root of trust lives closer to the Microsoft side of that line: accounts hold organizational data, and support staff are reachable by the same social-engineering playbook AA23-320A documents. Therefore the override stands only where a written regulatory or operational requirement exists, scoped to those user segments, with the risk decision recorded. That is the same carve-out Microsoft offers its own customers through customer-managed telecom providers.

Magic links promise email OTP with one less step: possession of the inbox becomes a click. Two things weigh against them as the fallback here. First, on Cognito they are a build. No native first factor produces a sign-in link, so magic links mean custom authentication: CUSTOM_AUTH with Lambda triggers, running as a parallel client-based flow next to the choice-based USER_AUTH flow that passkeys require. Email OTP, by contrast, is a native factor in the same flow, one string in AllowedFirstAuthFactors. Second, the security properties do not improve for the extra work. A link is a bearer secret in an inbox, exactly as phishable as a code, and it brings delivery mechanics of its own. Mail security gateways that pre-fetch URLs can consume a single-use link before the user sees it. The link also often opens in a different browser context than the one that requested it, which breaks session continuity. A team already invested in custom auth flows can reasonably keep magic links for UX consistency. As the recovery fallback in this migration, they sit on the same trust root as email OTP at a higher build and operations cost.

Where the Default Holds#

For a SaaS product whose accounts already recover through email, the default above holds: passkeys as the primary factor with user verification required, a second credential prompted early, email OTP as the self-service floor, re-verification above it, and assisted recovery run as a privileged operation. It bends in two places. Consumer products at a scale where lockout outweighs compromise can keep a broader fallback, with the risk stated in the design rather than inherited silently; and a regulated segment with a written telecom requirement keeps SMS scoped to exactly those users and no further. The useful first step precedes every API call above: fix the relying party ID and write the recovery ladder down, because both harden the moment the first credential exists.

References#

Related posts