When to Use
Use this skill when building or reviewing the authentication gate in front of a custody portal, hot-wallet administration console, exchange API key management screen, or signing-key operation. Traditional MFA — SMS, email OTP, TOTP, push-without-number-matching — is defeated by adversary-in-the-middle reverse proxies (Evilginx2 and successors), which relay a real login page and capture the resulting session. FIDO2/WebAuthn defeats that class because the authenticator signs over the origin the browser actually visited, and the proxy's origin is not one the server accepts.
The engine here is a Relying Party policy gate, not a WebAuthn library. It
sits behind py_webauthn or python-fido2 and enforces the server-side steps of
W3C WebAuthn Level 3 §7.2 that a library cannot do for you — the ones depending
on server state and firm policy: was this challenge issued by this server and
never used before, is this origin one this server serves, does this credential
belong to the user being claimed, has the signature counter gone backwards.
When NOT to Use
- As your WebAuthn implementation. It performs no cryptography on the
assertion and will not verify a signature. Passing
signature_verified=Truewithout a library behind it produces an authoritative-looking approval that checked nothing. - For the registration ceremony. §7.1 verification, attestation, and AAGUID provenance are out of scope; an AAGUID is self-asserted unless you verify attestation.
- As a transaction approval control. WebAuthn authenticates a person, not a
payload. Binding an approval to the exact transfer being approved is
multi-signature-approval-for-large-transfers. - As a session control. Session lifetime, re-authentication intervals and device binding belong in your session layer.
- Across multiple processes without a shared store. Challenge, credential and counter state is in-process; two workers with two challenge tables each accept the same replayed assertion once.
Prerequisites
- A working WebAuthn verifier whose signature result you can pass in.
- Server-side configuration of
rp_idand the exactallowed_originsyou serve — from configuration, never from the assertion being verified. - Credentials registered against their owning
user_id, with the registration- time BE flag captured. - At least two authenticators enrolled per user before enforcement; a device-bound credential is not resilient to device loss.
Workflow
- Issue a server-side single-use challenge:
issue_challenge(user_id)returns 32 bytes of entropy bound to one user. A freshness window over a client-supplied timestamp is not replay protection — the attacker supplies the timestamp. Only equality against a stored, single-use, server-generated value is (WebAuthn L3 §13.4.3). - Consume the challenge before anything else: the challenge is spent on first use whatever the outcome, so one captured value cannot be probed repeatedly against different origins and flag combinations until something passes.
- Bind origin and RP ID to server expectations: exact-match
clientDataJSON.originagainstallowed_origins, andauthenticatorData[0:32]against SHA-256 of the configuredrp_id. Never derive the expected origin by concatenatinghttps://with an RP ID taken from the response — that lets attacker-controlled input define the expectation it is checked against. - Bind the credential to the user: reject an unknown credential, a revoked
one, or one registered to a different account. Without this, any valid
assertion from any enrolled key authenticates any claimed
user_id. - Apply flag policy: UP and UV are separate §7.2 steps and separate failures — a missing touch is not a missing PIN, and conflating them misdirects the incident response. Reject the BE=0/BS=1 combination §6.1.3 disallows, and reject a BE that contradicts registration, since BE never changes.
- Check the signature counter last, under the same lock as the state update: a non-advancing counter is the specification's clone signal. §7.2 leaves the response to the RP; this engine rejects by default for custody.
- Defer state updates until every check passes, then read
report.statusandreport.audit_notes— not the truthiness of the report, which is a populated object on every rejection too.
Full procedure: see
references/workflows.md. Standards reference: seereferences/standards.md. Printable pre-flight checklist: seeassets/checklist.md.
Common Pitfalls
- Calling a timestamp check "replay protection": verifying that
now - assertion.timestamp < 60proves nothing when the assertion — and its timestamp — is what the attacker replays. WebAuthn's anti-replay property comes from a server-generated challenge compared for equality and used exactly once. A future-dated timestamp under the naive check never expires at all. - Deriving the expected origin from the response: computing
expected = "https://" + assertion.rp_idand then checking membership ofrp_idin an allowlist looks like origin binding, but the expectation is built from the input under test. The RP must compare against its own configured origin list (§13.4.9), which is also the only way multi-origin and related-origin deployments work — an RP ID may legitimately be a registrable suffix of the origin's domain. - Verifying an assertion without binding it to a user: a signature that verifies proves someone holds a registered key, not that they hold this account's key. Without a credential→user record, an attacker with any enrolled authenticator authenticates as anyone.
- Reporting the claim instead of the finding: writing an assertion's
self-declared
user_verifiedinto a rejected report'sis_user_verifiedfield puts attacker-supplied data into the column an auditor reads as established fact. A rejected assertion verified nobody. - Conflating user presence with user verification:
UP=0means no physical touch;UV=0means no PIN or biometric. Logging the first asUSER_VERIFICATION_FAILEDsends the investigation after the wrong failure. - Leaving a phishable fallback in place: an SMS backup, a TOTP re-enrolment, or a help-desk reset that can register a new credential collapses the control to the strength of that path. The enrolment and recovery routes need protection equivalent to the login they guard.
- Treating a syncable passkey as a hardware key: a BE=1 credential lives in a
vendor cloud account, which becomes part of your custody attack surface and is
usually protected more weakly than the custody portal. Decide deliberately via
require_device_bound_credential. - Citing regulators for requirements they did not write: NYDFS §500.12 requires MFA for any individual accessing any information system (since 2025-11-01) but is technology-neutral and does not mandate phishing-resistant MFA. NIST SP 800-63B-4 defines phishing resistance at AAL3 but binds federal agencies, not private custodians.
Verification
- Register a credential, issue a challenge, and verify a well-formed assertion
from
https://custody.firm.comwith UP=1, UV=1 and an advancing counter ⇒AUTH_SUCCESSFUL, and confirm the stored counter advanced. - Replay the same challenge a second time ⇒
CHALLENGE_UNKNOWN. Run eight threads against one challenge and confirm exactly one succeeds. - Submit an otherwise perfect assertion from
https://cust0dy-firm.com⇒ORIGIN_MISMATCH_PHISHING_ATTEMPT, and confirmis_user_verifiedisFalseon that report despite the assertion claiming UV=1. - Submit an assertion for a credential registered to another user ⇒
CREDENTIAL_USER_MISMATCH; for a revoked credential ⇒CREDENTIAL_REVOKED. - Date a challenge 600s in the future ⇒
CHALLENGE_EXPIREDrather than indefinite acceptance; at exactlymax_challenge_age_sec⇒ still accepted. - Submit UP=0 ⇒
USER_PRESENCE_MISSING(notUSER_VERIFICATION_FAILED), andsignature_verified=False⇒SIGNATURE_NOT_VERIFIED. - Submit a counter that does not advance ⇒
SIGN_COUNT_REGRESSION_CLONE_SUSPECTED, and confirm a rejected assertion left the stored counter untouched. - Configure
allowed_origins=(),"http://...", or a trailing slash ⇒PhishingResistantAuthErrorat construction rather than a silent lockout. - Run
python -m unittest discover -s skills/phishing-resistant-authentication-for-custody-access/scriptsand confirm a 100% pass rate.