Author: canutethegreat

  • MFA that survives phishing: TOTP, push and FIDO2 passkeys compared

    MFA that survives phishing: TOTP, push and FIDO2 passkeys compared

    Part 2 of 4 in the Authentication done right series. Part 1: Password storage in 2026.

    In the first part of this series I covered how to store passwords so a database leak doesn’t hand attackers working credentials. This part assumes the password is already gone, because sooner or later it will be. Phished, reused, scraped off an infostealer log. The question is what your second factor does when someone is actively trying to get past it, and the honest answer for most deployed MFA is “less than you’d think”.

    Not all second factors are the same control

    Two sequence diagrams side by side: a phishing proxy relays a password and TOTP code to the real site and steals the session cookie, while with WebAuthn the origin-bound credential is not offered on the fake domain and the login fails

    People talk about MFA as if it were one thing. In practice the methods fall into two groups. Shared secret and approval based factors (SMS codes, TOTP apps, push notifications) prove the user has a device, but the proof can be relayed by whoever the user is talking to. Public key factors bound to an origin (FIDO2/WebAuthn security keys and passkeys) produce proof that only works for the site that asked for it. That distinction is what CISA means by phishing resistant MFA in its 2022 guidance, and it’s what OMB memo M-22-09 required US federal agencies to adopt.

    SMS is the weakest of the first group. SIM swaps and number porting fraud move the phone number to an attacker, and NIST SP 800-63B has long classed SMS and voice as a restricted authenticator. I’d still take SMS over no second factor, but I wouldn’t let it protect anything I cared about.

    TOTP and the relay problem

    TOTP (RFC 6238, built on HOTP from RFC 4226) derives a six digit code from a shared secret and the current 30 second time step. It’s offline, cheap and works everywhere. It also has nothing in it that identifies which website the user is typing it into.

    That’s the whole weakness. An adversary-in-the-middle kit like Evilginx sits between the victim and the real login page as a reverse proxy. The victim sees what looks like the real site, types their password and their TOTP code, and the proxy forwards both in real time and keeps the resulting session cookie. The code was valid. It just went to the wrong party.

    The 2022 “0ktapus” campaign worked this way at scale. SMS messages pointed employees of Twilio and many other companies to lookalike Okta pages that captured credentials and one time codes as they were typed. Cloudflare was targeted by the same campaign, and some of its employees did enter their credentials. The attack still failed there, because Cloudflare required FIDO2 hardware keys and the fake domain couldn’t get a valid assertion from them. Cloudflare’s own write-up of that incident is one of the clearest arguments for WebAuthn I know of.

    If you do run TOTP, at least implement it properly. Accept a small clock skew window and reject codes that have already been used.

    from datetime import datetime, timezone
    import pyotp
    
    def check_totp(user, code):
        totp = pyotp.TOTP(user.totp_secret)
        # accept the current step and one on either side for clock drift
        if not totp.verify(code, valid_window=1):
            return False
        step = totp.timecode(datetime.now(timezone.utc))
        if user.last_totp_step is not None and step <= user.last_totp_step:
            return False  # replayed code
        user.last_totp_step = step
        user.save()
        return True

    Rate limit verification attempts too. A six digit code has a million values, which is not a lot if an endpoint lets someone try thousands of codes per account.

    Push approval and MFA fatigue

    Push MFA looked like an upgrade because the user doesn’t type anything. It created a different failure. If an attacker has the password, they can trigger approval prompts over and over until the user taps Approve to make it stop, or until someone calls pretending to be IT and asks them to accept.

    That’s what happened at Uber in September 2022. According to Uber’s own statement, the attacker had a contractor’s password, sent repeated push requests, and the contractor eventually accepted one. Cisco’s breach that year, disclosed in August, combined voice phishing with the same kind of repeated push prompts. In both cases the MFA product behaved exactly as designed.

    The fixes are real but partial. Number matching shows a code on the login screen that the user has to enter in the authenticator app, so a blind tap no longer works; Microsoft made it mandatory for Authenticator push in May 2023. Showing the location and application in the prompt helps a little. Limiting how many prompts can be sent per hour and alerting on bursts helps more. None of it stops a real-time phishing proxy, because the user is looking at a page that displays the number too.

    Why WebAuthn is different

    With WebAuthn, the browser builds a clientDataJSON structure that includes the challenge and the origin the user is actually on, and the authenticator signs it along with the relying party ID hash. Credentials are scoped to the relying party ID, so an authenticator won’t even offer a credential for example.com to a page on examp1e-login.com. There’s no code for the user to copy, so there is nothing to relay.

    The server side still has to do its part. Every serious library makes you pass the expected origin and RP ID, and you should never loosen those to make a test environment work.

    from webauthn import verify_authentication_response
    
    verification = verify_authentication_response(
        credential=assertion_from_browser,
        expected_challenge=session.pop("webauthn_challenge"),
        expected_rp_id="example.com",
        expected_origin="https://example.com",
        credential_public_key=stored.public_key,
        credential_current_sign_count=stored.sign_count,
        require_user_verification=True,
    )
    stored.sign_count = verification.new_sign_count

    The challenge must be random, single use and tied to the session that requested it. I’ve reviewed implementations that reused a static challenge, which turns a signature scheme back into something replayable.

    Passkeys, synced and device bound

    Passkeys are WebAuthn credentials, usually discoverable ones, and most are synced through a platform account such as iCloud Keychain or Google Password Manager. That’s great for adoption, because users who lose a phone don’t lose their credentials. It also means the security of the passkey now depends on the security of that cloud account and its recovery process.

    For most consumer apps, synced passkeys are a large improvement over passwords plus TOTP. For administrators and production access I still want device bound credentials on hardware keys, and I want attestation checked so I know what kind of authenticator registered.

    Recovery is part of your MFA

    Attackers go where the controls are weakest, and that’s usually account recovery. A strong factor with a “lost your key? we’ll send an SMS” fallback is an SMS factor with extra steps. Help desk resets are the same problem with a human in the loop. The MGM Resorts intrusion in 2023 was widely reported to have started with a call to the help desk.

    So register at least two strong authenticators per user, make recovery require something stronger than knowledge of public facts, and log and alert on every factor reset. Treat a new factor enrolled right after a password reset as a high priority event.

    The next part moves from authenticating a person to delegating access between systems, which means OAuth 2.0, OpenID Connect and the token mistakes that come with them.

    Other parts: Part 1: Password storage in 2026, Part 3: OAuth 2.0 vs OpenID Connect, Part 4: Session management.

  • Password storage in 2026: bcrypt, scrypt, Argon2id and parameters that actually mean something

    Password storage in 2026: bcrypt, scrypt, Argon2id and parameters that actually mean something

    Part 1 of 4 in the Authentication done right series.

    Most teams I talk to already know they shouldn’t store passwords with plain SHA-256. Fewer can tell me what cost parameters their hash uses, when those were last revisited, or what happens to the users who signed up five years ago under weaker settings. Picking the algorithm is the easy part. The parameters and the upgrade path are where real systems fall behind.

    Why fast hashes fail

    Flowchart: parse the stored hash parameters, verify the password, then rehash to current Argon2id policy if the stored parameters are weaker, before issuing a session

    A password hash has one job: make each guess expensive for someone who has stolen your database. General purpose hashes like SHA-256 are designed to be fast, and a single modern GPU computes billions of them per second. When LinkedIn’s 2012 dump of unsalted SHA-1 hashes surfaced publicly in 2016 with roughly 117 million credentials, most of it was cracked quickly. Salting would have stopped the precomputed table attacks. It would not have helped much against brute force, because each individual guess still cost almost nothing.

    Adobe’s 2013 breach is the other cautionary tale. Those passwords were encrypted with 3DES in ECB mode, not hashed, so identical passwords produced identical ciphertext and the plaintext password hints sitting next to them did the rest. Encryption is the wrong tool here. You never need to recover a password, only to check one.

    So the requirements are a unique random salt per password and a function that is deliberately slow and, ideally, memory hungry. Memory hardness matters because GPUs and ASICs have lots of compute but limited fast memory per core. Forcing every guess to touch tens of megabytes destroys their parallelism advantage.

    The three you should consider

    bcrypt dates from 1999 and is still fine. It’s CPU hard with a tunable cost (each increment doubles the work), it’s in every language, and it has a long track record. It has two sharp edges. It is not memory hard in any meaningful sense (it uses about 4 KB), and it silently ignores input past 72 bytes.

    That second one is not academic. In late 2024 Okta disclosed that its AD/LDAP delegated authentication cache keys were bcrypt hashes of the user ID, username and password concatenated together. With a username long enough (52 characters or more), the password fell past the 72 byte cutoff, so a cached login could succeed with any password. Nothing was wrong with bcrypt’s math. The input simply got truncated without an error.

    scrypt (RFC 7914) was the first widely used memory hard function. It’s tunable through N (CPU/memory cost), r (block size) and p (parallelism), and memory use is roughly 128 x N x r bytes. N=2^17, r=8, p=1 works out to 128 MiB per hash, which is the OWASP baseline.

    Argon2id won the Password Hashing Competition in 2015 and is specified in RFC 9106. It takes memory in KiB, a number of passes, and a degree of parallelism. The “id” variant mixes data independent and data dependent memory access, giving reasonable resistance to both side channel and GPU attacks. It’s my default for anything new.

    PBKDF2 stays on the list only because FIPS-140 environments sometimes require it. If you’re stuck with it, OWASP currently says 600,000 iterations of HMAC-SHA256. It is not memory hard, so treat it as the compliance option, not the good one.

    Choosing parameters with real numbers

    The published recommendations are a floor, not a target. OWASP’s Password Storage Cheat Sheet lists Argon2id at a minimum of m=19 MiB, t=2, p=1, with equivalent tradeoffs like m=46 MiB, t=1. RFC 9106 is more aggressive: its second recommended option is t=3, m=64 MiB, p=4, and the first option uses 2 GiB, which is meant for settings like disk encryption rather than a login endpoint.

    What I actually do is benchmark on the production hardware and work backwards from two constraints. The first is latency: how long can a login take before users notice? Somewhere around 100 to 500 ms of hashing is usually acceptable. The second is concurrency: memory cost times the number of simultaneous logins you need to survive. At 64 MiB per hash, 50 concurrent logins need 3.2 GiB just for hashing. Get that wrong and a credential stuffing wave becomes an out-of-memory event on your auth service.

    The reference argon2 CLI makes the measurement easy. Note that -m is the base-2 log of the memory in KiB, so 16 means 64 MiB.

    $ echo -n 'correct horse battery staple' | argon2 somesaltvalue -id -t 3 -m 16 -p 4
    Type:           Argon2id
    Iterations:     3
    Memory:         65536 KiB
    Parallelism:    4
    Hash:           ...
    Encoded:        $argon2id$v=19$m=65536,t=3,p=4$...
    0.183 seconds

    Increase memory first until you hit your concurrency budget, then add passes until you hit your latency budget. Memory is what hurts attackers most.

    Store parameters with the hash, and rehash on login

    The encoded string format (the $argon2id$v=19$m=65536,t=3,p=4$salt$hash form above) carries the algorithm and parameters alongside the salt and hash. That’s what makes upgrades possible. You verify with whatever parameters the stored hash says, and if they’re weaker than your current policy, you rehash the plaintext you just verified and write the new value back.

    from argon2 import PasswordHasher
    from argon2.exceptions import VerifyMismatchError
    
    ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
    
    def login(user, password):
        try:
            ph.verify(user.password_hash, password)
        except VerifyMismatchError:
            return False
        if ph.check_needs_rehash(user.password_hash):
            user.password_hash = ph.hash(password)
            user.save()
        return True

    Active users migrate themselves within a few months. For the long tail of accounts that never log in, you have two options. You can wrap the old hash (store argon2id(old_sha1_hash) and verify by computing both layers), which upgrades everyone immediately without needing their passwords. Or you can expire those passwords and force a reset. I’ve used wrapping for legacy MD5 and SHA-1 tables and it works well, as long as you mark which rows are wrapped.

    Peppers and pre-hashing

    A pepper is a secret key applied to every password, kept outside the database, ideally in a KMS or HSM. The common construction is to HMAC the password with the pepper before hashing it. If an attacker gets a SQL injection dump but not the application secrets, the hashes are useless to them. The cost is operational: rotating a pepper means keeping old keys around and tracking which one each hash used.

    Pre-hashing is also how people get around bcrypt’s 72 byte limit, and it’s easy to get wrong. If you bcrypt a raw SHA-256 digest, the binary output can contain NUL bytes that some bcrypt implementations treat as the end of the string. Base64 encode the digest first. And use HMAC with a pepper rather than a bare hash, otherwise an attacker holding a separate leak of unsalted SHA-256 hashes can test them directly against your bcrypt values without cracking anything, a technique known as password shucking.

    The cleaner answer is to use Argon2id and not need any of this. If you’re on bcrypt and staying there, at least reject or explicitly handle inputs over 72 bytes so the truncation is never silent.

    What I’d check on any existing system

    Open the users table and look at a few hash prefixes. $2b$10$ is bcrypt at cost 10, $argon2id$ tells you parameters inline, and a bare 40 or 64 character hex string is a fast hash you need to fix this quarter. Then check whether the login path rehashes, whether the memory cost was ever benchmarked against peak concurrency, and whether anything concatenates fields before passing them to bcrypt. Most of what I find in reviews sits in that last check.

    Storing passwords well protects users after a breach. Next in this series is about making a stolen or phished password much less useful in the first place, which means MFA that holds up against phishing.

    Other parts: Part 2: MFA that survives phishing, Part 3: OAuth 2.0 vs OpenID Connect, Part 4: Session management.

  • Censorship Pisses Me Off!

    Censorship Pisses Me Off!

    Censorship pisses me off! Seriously! It especially pisses me off when it comes to natural things (e.g. nudity) when other topics, such as violence, are totally accepted. I’m fucking tired of it.

    It has been said by many others, but I’ll repeat it with my own addendum: those who control the money control everything else eventually!

    It often starts small, and we may even make justification at the time regarding the reasoning, but it is never just a one-off: it always grows, or rather erodes, over time. Safety and risk are often used as excuses, but they are not the only ones that carry weight.

    Liberty (social) and Freedom (individual) are not the same thing and it is difficult to strike a balance between the two, but it can, and has, been done (e.g. Twelve Tables of Roman Law (451–450 BCE) [Lex Duodecim Tabularum]).

    It is very rare for either Liberty or Freedom to be restored once one has been taken away. It is usually a one-way, slippery slope. It can happen quickly, or it can happen slowly (i.e. death by a 1000 [paper]cuts). In the end, the result is always the same.

    Short of a major event (e.g. revolution) it typically cannot be undone.

    The next time you get pissed off at something, try and think about the long term, multigenerational effect, before you jump on the “this should be banned” bandwagon.

    If we do not have an independent, localized group to catch and filter public anger, a democracy will collapse into a centralized tyranny of the majority.

    FYI that last sentence is a paraphrase from Tocqueville, Democracy in America, Volume 1, Part 2, Chapters 7 and 8 (where he coins the phrase “tyranny of the majority”), and Volume 2, Part 4 (where he warns how centralized states absorb “secondary powers”).