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 FalsePasskeys 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:
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.