Skip to main content
Security for Application Engineers

AuthN: Passwords, Passkeys, MFA

Ravinder··6 min read
SecurityAppSecAuthenticationPasskeysMFA
Share:
AuthN: Passwords, Passkeys, MFA

Authentication is the first gate, and it fails in remarkably consistent ways: passwords stored with MD5 or SHA-1, MFA bolted on as an afterthought with a bypassable flow, passkey adoption deferred indefinitely because "our users won't get it." Meanwhile, credential stuffing attacks are largely automated and indiscriminate — they hit every service running a password endpoint.

This post is about what to actually ship in 2026. Not theoretical best practice — concrete decisions about hashing algorithms, passkey flows, MFA tier selection, and the specific failure modes that break implementations in production.

Password Storage: The Only Acceptable Algorithms

If your database leaked today, how long would it take an attacker to crack your password hashes? With bcrypt (cost factor 12) the answer is years per hash on a single GPU. With SHA-256 (unsalted), minutes for the entire table using precomputed rainbow tables.

Acceptable algorithms in 2026:

Algorithm When to Use Notes
Argon2id New systems, greenfield Winner of Password Hashing Competition; memory-hard
bcrypt Existing systems, well-understood Cost factor ≥ 12; max 72-byte input — hash long passwords first
scrypt Systems with tunable memory cost OWASP recommends N=32768, r=8, p=1 minimum

Never use: MD5, SHA-1, SHA-256/512 alone, PBKDF2 with fewer than 600 000 iterations (OWASP 2023 guidance).

# Python — Argon2id with argon2-cffi
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
 
ph = PasswordHasher(
    time_cost=2,        # iterations
    memory_cost=65536,  # 64 MiB
    parallelism=2,
    hash_len=32,
    salt_len=16,
)
 
def register(password: str) -> str:
    return ph.hash(password)
 
def verify(stored_hash: str, candidate: str) -> bool:
    try:
        ph.verify(stored_hash, candidate)
        # rehash if parameters have changed
        if ph.check_needs_rehash(stored_hash):
            return True  # caller must update stored hash
        return True
    except VerifyMismatchError:
        return False

Passkeys in 2026: WebAuthn Is Ready

Passkeys (WebAuthn + discoverable credentials synced via platform) eliminate the entire password database problem. No shared secret lives on your server — you store a public key; the private key never leaves the user's device or password manager.

The registration and authentication flows:

sequenceDiagram participant Browser participant RP as Relying Party (Your Server) participant Authenticator Note over Browser,Authenticator: Registration Browser->>RP: POST /passkey/register/begin {userId} RP-->>Browser: PublicKeyCredentialCreationOptions (challenge, rpId) Browser->>Authenticator: navigator.credentials.create(options) Authenticator-->>Browser: PublicKeyCredential (attestation, publicKey) Browser->>RP: POST /passkey/register/complete {credential} RP-->>Browser: 200 OK (stores publicKey + credentialId) Note over Browser,Authenticator: Authentication Browser->>RP: POST /passkey/auth/begin {userId?} RP-->>Browser: PublicKeyCredentialRequestOptions (challenge) Browser->>Authenticator: navigator.credentials.get(options) Authenticator-->>Browser: PublicKeyCredential (assertion, signature) Browser->>RP: POST /passkey/auth/complete {assertion} RP-->>Browser: Session token (verified signature against stored pubkey)

Server-side, use a well-maintained WebAuthn library rather than rolling your own CBOR/COSE parsing.

// Node — using @simplewebauthn/server
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";
 
const rpID = "arika.dev";
const rpName = "Arika";
const origin = "https://arika.dev";
 
export async function beginRegistration(userId: string, username: string) {
  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userID: Buffer.from(userId),
    userName: username,
    authenticatorSelection: {
      residentKey: "required",     // discoverable credential
      userVerification: "required", // biometric or PIN
    },
  });
  // store options.challenge in session keyed by userId
  return options;
}
 
export async function completeRegistration(
  response: RegistrationResponseJSON,
  expectedChallenge: string
) {
  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
  });
  if (verification.verified && verification.registrationInfo) {
    // persist verification.registrationInfo.credential to DB
  }
  return verification.verified;
}

MFA Tiers: What to Ship and When

Not all MFA is equal. Rank by phishing resistance:

Tier Method Phishing-resistant? Notes
1 (weakest) SMS OTP No SIM-swap vulnerable; better than nothing
2 TOTP (Authenticator app) No Real-time phishing still works; widely supported
3 Push notification Partially MFA fatigue attacks; require number matching
4 (strongest) Hardware key / Passkey Yes WebAuthn origin-bound; ideal

Minimum viable for a new product in 2026: TOTP at registration, with passkey as upgrade path. SMS-only is not acceptable for any account with elevated privileges.

# TOTP verification — pyotp
import pyotp, time
 
def verify_totp(secret: str, user_code: str) -> bool:
    totp = pyotp.TOTP(secret)
    # valid_window=1 allows ±30 second drift
    return totp.verify(user_code, valid_window=1)
 
def provision_totp(user_id: str) -> dict:
    secret = pyotp.random_base32()
    uri = pyotp.totp.TOTP(secret).provisioning_uri(
        name=user_id,
        issuer_name="Arika"
    )
    return {"secret": secret, "otpauth_uri": uri}

The MFA Bypass Pitfalls

Shipping MFA is not the same as enforcing MFA. Common bypass patterns:

1. The "Remember this device" token is never invalidated. If you issue a long-lived cookie that skips MFA, ensure it is tied to the device fingerprint and expires on password change.

2. Password-reset flow skips MFA. An attacker who can receive a reset email lands on an authenticated session without passing MFA. Require MFA re-verification after password reset, or treat the reset token as single-factor and force MFA enrollment.

3. API endpoints accept only a password, not session tokens. Mobile and API clients that bypass the browser MFA flow entirely. All token issuance paths must traverse the same AuthN gate.

4. MFA enrollment is optional and low-friction to disable. Users who never enroll are permanently on single-factor. Set a grace period, then enforce.

Account Lockout and Rate Limiting

Credential stuffing runs millions of attempts. Lockout policy:

import redis, hashlib, time
 
r = redis.Redis()
 
LOCKOUT_THRESHOLD = 5   # attempts
LOCKOUT_WINDOW = 300    # seconds
LOCKOUT_DURATION = 900  # seconds (15 min)
 
def check_lockout(identifier: str) -> bool:
    """identifier: IP or username — track both separately."""
    key = f"lockout:{hashlib.sha256(identifier.encode()).hexdigest()}"
    attempts = r.get(key)
    return attempts is not None and int(attempts) >= LOCKOUT_THRESHOLD
 
def record_failure(identifier: str):
    key = f"lockout:{hashlib.sha256(identifier.encode()).hexdigest()}"
    pipe = r.pipeline()
    pipe.incr(key)
    pipe.expire(key, LOCKOUT_WINDOW)
    pipe.execute()

Track both IP and username independently. Username-based lockout is DoS-able; IP-based lockout misses distributed attacks. Use both, with CAPTCHA at lower thresholds.

Key Takeaways

  • Use Argon2id for new systems; bcrypt (cost ≥ 12) is acceptable for existing ones — MD5 and raw SHA are categorically wrong and must be migrated.
  • Passkeys eliminate the shared-secret problem entirely; WebAuthn libraries are mature enough to ship in 2026 without rolling your own crypto.
  • MFA strength varies enormously — TOTP is phishable, hardware keys and passkeys are not; require phishing-resistant MFA for privileged accounts.
  • MFA enforcement requires covering every token-issuance path, including password reset, API clients, and social login, not just the primary login form.
  • Rate-limit and lock out on both IP and username independently; neither alone is sufficient against modern credential-stuffing tooling.
  • Treat every "remember this device" shortcut as a security decision that needs an expiry and an invalidation path, not a convenience feature.
Share: