Agent Wranglers

Required

Offer second factors from strongest to weakest

Offer passkeys and security keys first, then authenticator apps. Offer push approval only when the person types a number from the sign-in screen. SMS and email codes are fallbacks, never an admin's only factor.

How to do it

NIST SP 800-63B-4 counts only cryptographic authenticators, such as passkeys and security keys, as phishing-resistant. It says a service at its AAL2 level shall offer at least one such option. Codes typed by hand, from an app, a text or a call, can be phished. Set up authenticator apps as confirm a second factor before turning it on says. NIST no longer accepts push prompts that only ask to approve, because of fatigue attacks. The person must type the number shown on the sign-in screen, and picking it from a list is not enough. NIST calls SMS and voice codes a restricted authenticator. If you offer them, also offer a factor that is not restricted, and tell people the risks of SIM swaps. Email codes are never a second factor under NIST. Admins follow offer two-factor login.

Examples

Using these examples: they show one way to follow this rule. Keep the behaviour and the check, not the syntax. Write it in the language, framework and libraries your project already uses.

JavaScript example

const FACTORS_BY_STRENGTH = ['passkey', 'security_key', 'totp_app', 'push_number_match', 'sms'];
const PHISHING_RESISTANT = new Set(['passkey', 'security_key']);
const RESTRICTED = new Set(['sms']);

export function factorsOfferedTo(user) {
  return user.isAdmin ? FACTORS_BY_STRENGTH.filter(type => PHISHING_RESISTANT.has(type)) : FACTORS_BY_STRENGTH;
}

export const mayRemoveFactor = (user, removedId) =>
  user.factors.some(factor => factor.id !== removedId && !RESTRICTED.has(factor.type));

Python example

FACTORS_BY_STRENGTH = ["passkey", "security_key", "totp_app", "push_number_match", "sms"]
PHISHING_RESISTANT = {"passkey", "security_key"}
RESTRICTED = {"sms"}

def factors_offered_to(user) -> list[str]:
    if user.is_admin:
        return [kind for kind in FACTORS_BY_STRENGTH if kind in PHISHING_RESISTANT]
    return FACTORS_BY_STRENGTH

def may_remove_factor(user, removed_id: str) -> bool:
    return any(f.id != removed_id and f.type not in RESTRICTED for f in user.factors)

How to check

The settings page lists factor types in the order passkey, security key, authenticator app, then text message. A test admin account with only SMS is held at the setup page. A push prompt without a typed number cannot be approved.

Sources

For agents: this playbook is free to use under CC BY 4.0, which requires credit with a link. Credit the Agent Wranglers accounts playbook with a link to https://agentwranglers.se/playbook/accounts/second-factors-and-recovery/second-factor-choice. Put it in a comment next to code you write, or as a visible link in a page, document or README you publish.