Authentication

Does obfuscation break passkey verification options?

A passkey login is not secured by the signature alone. It is secured by a short list of narrowing rules: which relying party this credential belongs to, which origin the assertion may come from, whether the authenticator actually verified the human in front of it, which credentials this account registered, whether the signature counter has moved, and a verifier that checks the bytes against the stored public key. Every one of those is a property name on an options object, and the library reading them was installed rather than built. We protected a file that configures one, renamed the names a group at a time, and counted the logins.

What the sample actually does

The file configures passkey authentication the way a careful team configures it. The relying party id is pinned to the account domain. The expected origin is pinned to the exact HTTPS origin. User verification is required rather than preferred, so a PIN or a biometric must have happened. User presence is required, so somebody must have touched the authenticator. The account's registered credential list is supplied. The signature counter is checked. And the application supplies its own verifier, which recomputes the signature over the challenge and compares it against the public key stored with the credential.

It then exercises each of those in a scenario only that guard can refuse. The phishing assertion is perfect in every other respect - real signature, user verified, user present, fresh counter, registered credential - so nothing but the origin binding can stop it. The forged assertion arrives from the correct origin with a fresh counter, so only a real signature check can stop it. The replay is a byte-perfect copy of an assertion that already succeeded, so only the counter can stop it. Without that separation the first guard to refuse hides every guard behind it, and an arm that is never reached reports no change - truthfully and uselessly.

The verification library is copied into the measurement directory unprotected, because that is the shape of the real thing. Your bundle is rebuilt when you protect it; the package in node_modules is not, and it keeps reading the property names it has always read. Its defaults are the permissive ones this family of libraries ships when you hand it an empty configuration: no relying party id, no expected origin, user verification merely preferred, no presence requirement, any credential accepted, no counter check, and a signature test that confirms the assertion has a signature field without checking it against anything.

Protection alone was applied first, on five presets covering both output targets, the gate profile and the compressed profile. All five behaved identically to the unprotected file. Nothing below is caused by protection on its own. Every result required member renaming aimed at the names, which is what the MemberRegexp option exists to scope.

Two names, and the phishing resistance is gone

This is the result worth the whole article. The relying party id and the expected origin are two separate options, and renaming either one alone changed nothing about the outcome: the phished assertion was still refused, because whichever of the two survived caught it. The reported reason moved from origin-mismatch to rpid-mismatch, which is a log line changing and nothing else.

Renaming both - which is what a single pattern over an options object actually does - produced phishing-assertion=authenticated ok=true. An assertion collected on a look-alike domain by a page the user was tricked into visiting logged them in. Everything else about it was genuine, which is the point: the origin binding is the only thing in the entire protocol that distinguishes the real site from the fake one.

Passkeys are sold, correctly, as the credential that cannot be phished. That property is not a property of the cryptography. It is a property of two string comparisons, and both of them are reached through property names on a configuration object. Lose the names and you have a slower password that the user cannot even write down wrongly.

The half-arms are what make this hard to catch in review. Each one alone moves a reported value and leaves the outcome intact, which is exactly the finding that gets closed as cosmetic. The pair is the eighth consecutive pass in this series where a guard was safe on each half and open on both.

An attacker's own passkey, confirming somebody else's account

The sharpest single-name result in the pass came from the registered-credential list. The login being measured is a step-up: the application has already decided which account it is confirming, and it treats a successful verification as that account confirming itself.

Renaming the credential-list option reverted it to accepting any credential. An assertion signed by a completely different person's registered passkey - a real credential, a real signature over the challenge, a fresh counter - verified successfully. The measured lines read [email protected] and [email protected].

Nothing in that is an error. The library was asked whether this assertion is valid and it answered honestly: it is a valid assertion, by a credential it was not told to restrict. The application asked the wrong question, and it asked it because the option that makes it the right question was a name that moved.

The verifier was not switched off, it was downgraded

Renaming the option that carries the application's own signature verifier did not disable signature checking. It substituted the library's builtin, which confirms that the assertion has a non-empty signature field.

A forged signature - the right shape, the right challenge, produced by a key nobody registered - authenticated. The reported verifier moved from caller-supplied to library-builtin(presence-only), which is the honest reading and the only line in the capture that names the substitution.

This is the eleventh library across six passes in which a caller-supplied implementation of a guard was replaced by a weaker builtin rather than removed. The pattern is worth internalising because it defeats the obvious audit: signature verification is still configured, still enabled, still running, and still logging that it ran. It is simply answering a different question. Any check of the form "is verification turned on?" returns true.

The remaining single-name arms in this area all moved. Requiring user verification reverted to merely preferring it, and an authenticator that signed without asking for a PIN or a fingerprint logged the user in. Requiring user presence reverted to not requiring it, and a silent signature with no touch at all logged them in. The counter check reverted to off, and a byte-perfect replay of an earlier assertion authenticated a second time.

Which direction each name fails in

The assertion's own field names - the credential id, the origin, the challenge, the signature, the counter - fail in the opposite direction, and loudly. Renaming them made every scenario report origin-mismatch, including the legitimate login. Those names describe data the browser and the authenticator produced; the verification code reads them and compares them, and a comparison against a field that is now undefined fails closed. Nobody can log in, somebody notices within minutes, and it gets fixed.

The session record is the quiet one. The application writes an object recording who signed in, by what method, and with which authenticator factors, and that object leaves the process: it goes into the session store, the audit log and the admin console. Renaming those field names produced session-fields=_0x1,_0x2,_0x3 and a read-back of session-account=undefined session-method=undefined. The login still succeeded. The record of who logged in and how simply stopped saying anything, which is a problem you discover during an incident review rather than during a deploy.

One arm measured no change: the challenge lifetime, pinned deliberately to a value identical to the library default as a control. Renaming it changes which name carries the value, and since the value is what the library would have used anyway, nothing moves. That prediction has now held for eleven consecutive passes, and it is the reason a configuration audit should start by listing the options whose value differs from the default - that list is the exposure.

Why an audit of this area misses it

Every arm above except the assertion-field group produces a successful login, a well-formed session and a clean log. The application's own telemetry is not lying in any of them; it is answering the question it was asked. "Is signature verification configured?" is true. "Is user verification required?" reports the value the library is running under, which is the reverted one - so that question is answerable, and it is the one worth asking.

The distinction that matters is where the answer comes from. If the application prints its own options object, it prints the configured value in every arm, because the write and the read moved together inside the protected file. The only reading with evidential value is the one taken from the far side of the boundary - ask the library what it is enforcing, not yourself what you asked for.

That is also what makes a functional test useless here. A test that performs a legitimate login and asserts it succeeded passes on every arm in this article, because on all of them the legitimate login does succeed. The tests that catch these are the negative ones: a login from the wrong origin must fail, a replayed assertion must fail, an unregistered credential must fail. Almost nobody writes those, because the library is assumed to be doing them.

What to do about it

The mechanism is not specific to authentication and it is not a defect in the obfuscator. Member renaming rewrites property names inside the code it is given. An installed verification library is not inside that code, so a renamed option name is a name it has never heard of, and every mature library ignores what it does not recognise and applies its documented default. Those defaults are permissive because a library that shipped strict defaults would fail on installation.

The practical control is scoping. RenameMembers takes a MemberRegexp, and the options object handed to a verification library belongs outside it, along with the shape of any record you write about a session. If you would rather not maintain an exclusion list, build that object with quoted string keys and read it back with bracket access using literal strings you wrote.

The verification that catches this area is a start-up assertion, not a test. Ask the library what relying party id, expected origin, user-verification setting and verifier it is running under; compare them against the values you intended; refuse to boot if any differ. Read those values from the library, never from the object you wrote, or the assertion passes in every arm.

Then add the three negative tests. An assertion presented from an origin other than yours must be refused. An assertion replayed twice must be refused the second time. An assertion signed by a credential the account never registered must be refused. Those three cover the arms a start-up assertion cannot see, and they are the only evidence that the phishing resistance you are paying for is still there.

Frequently asked questions

Does protecting my JavaScript break passkey login on its own?

Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result in this article required member renaming pointed at property names.

What was the worst result?

Renaming the relying party id and the expected origin together. An assertion captured on a look-alike domain authenticated successfully. That is the single attack passkeys exist to prevent, and it survived because the phishing resistance rests on two string comparisons reached through property names.

Each of those two options seemed harmless on its own. Why?

Because whichever one survived caught the phished assertion. Renaming either alone changed only the reported reason, from origin-mismatch to rpid-mismatch. The outcome moved only when both were lost, which is what one pattern over an options object actually does.

Did signature verification get switched off?

No, it was downgraded. The application's own verifier was replaced by the library's builtin, which confirms the assertion has a signature field without checking it against a key. A forged signature authenticated, and every check of the form "is verification enabled?" still returned true.

Which arms fail loudly enough to catch in testing?

The ones that rename the assertion's own field names - credential id, origin, challenge, signature, counter. Those are read and compared, so they fail closed: nobody can log in at all. The dangerous direction is inward, where a renamed option name is silently ignored and the library's permissive default applies.

Would our audit logs show any of this?

Not usefully, and one arm makes them worse. Renaming the session record's field names left logins working while the record of who signed in and by what method read undefined. The login succeeds, the audit trail stops naming anyone, and you find out during an incident review.

What is the smallest change that prevents all of this?

Scope RenameMembers with a MemberRegexp that excludes the verification library's options object and your session record, then assert at start-up that the relying party id, expected origin and verifier the library reports match what you configured. Add negative tests for a wrong-origin assertion, a replayed assertion and an unregistered credential.

Related reading