Passkeys bring WebAuthn into wallet UX. A device-held credential signs a browser challenge, then wallet logic decides whether that assertion authorizes an onchain action.
Two mismatches matter. The first is the key algorithm: most passkeys use
ES256, which means ECDSA over P-256, while Ethereum EOAs use ECDSA over
secp256k1 and derive the account address through ecrecover. The second is
the signed artifact: a passkey signs WebAuthn bytes, not a native chain
transaction. A passkey keypair cannot become an Ethereum EOA key, but an account
can choose to trust a WebAuthn assertion.
During authentication, the authenticator signs:
authenticatorData || sha256(clientDataJSON)The wallet controls the challenge inside clientDataJSON. It can bind that
challenge to a transaction digest, typed-data digest, session request, or
account operation. The signature still covers WebAuthn data: origin-bound
client data, authenticator flags, an RP ID hash, and a counter.
Useful verification has two layers:
The cryptographic check proves that the credential signed the WebAuthn bytes. The wallet policy decides whether those bytes authorize asset movement.
The useful comparison is not the signature bytes. It is whether a key shape can exist as a WebAuthn credential. Ethereum EOAs and Solana native accounts use chain-native key types; passkeys expose WebAuthn algorithms such as ES256, and less commonly RS256. The next section expands those labels into the curves, signature schemes, and chain rules behind them.
WebAuthn does not define "a passkey algorithm" as one universal curve family.
The credential public key is encoded as a
COSE_Key,
and the key carries a concrete alg value such as -7 for ES256 or -257 for
RS256. That alg is a full signing profile: key family, signature scheme, hash
function, and sometimes encoding expectations.
That is why both ES and RS exist. FIDO had to support real authenticator hardware that already came from different security lineages. Some authenticators were built around elliptic-curve keys because they are compact and cheap to move through constrained transports. Other enterprise and smart-card style systems already had RSA hardware, PKI tooling, certification paths, and verification infrastructure. WebAuthn inherited that interoperability requirement rather than pretending every authenticator would be the same kind of key.
This also explains the naming. ES256 is not an abstract "elliptic curve"
class. It is the JOSE/COSE convention for ECDSA plus SHA-256 on a specific
curve, with the curve itself stored in the COSE key parameters. RS256 is RSA
PKCS#1 v1.5 plus SHA-256. The names identify the whole verification contract so
that a relying party knows exactly how to parse the public key and verify the
signature. A looser class name like EC would still need a curve, hash,
signature encoding, and public-key representation before it could be verified.
There is some history in that shape. Earlier
FIDO UAF registries
named very specific algorithm-and-encoding constants such as
ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW and RSA SHA-256 variants, partly to keep
small authenticators simple and unambiguous. WebAuthn/FIDO2 moved the wire
format to CBOR and reused COSE algorithm identifiers, but kept the same basic
idea: algorithm negotiation is about exact suites, not broad cryptographic
families. Later registrations such as
RFC 8812 added more WebAuthn
COSE algorithms, including secp256k1, but that still does not make Ethereum EOA
signing a normal passkey shape.
ES256 is ECDSA with SHA-256 on the NIST P-256 curve. This is the common
passkey shape on phones and laptops. The public key is a P-256 point
(Qx, Qy), and the signature is an ECDSA pair (r, s). The signature
algorithm is familiar to Ethereum developers, but the curve is not the one
Ethereum EOAs use.
P-256 and secp256k1 are both 256-bit prime-field elliptic curves, and both can be used with ECDSA. That similarity is exactly what makes the mismatch easy to underestimate: ECDSA is the signing algorithm, but the curve fixes the group in which the public key and signature relation live.
The curve is not just a drawing. It defines the finite-field group used for
point arithmetic: the field prime, curve coefficients, generator point, and
group order. A private scalar only becomes a public key after multiplication by
that curve's generator: Q = dG. The
Elliptic Curves article walks through that point-addition geometry
in more detail. Change the curve and G, the point addition rules, and the
valid point set change with it, so the same scalar lands on a different public
key.
ECDSA signatures are checked inside that same group. The verifier rebuilds a
point relation from r, s, the message hash, the generator, and the public
key. If the public key lives on P-256, the relation must be verified on P-256;
if it lives on secp256k1, it must be verified on secp256k1. That is why a
P-256 passkey signature cannot be fed into Ethereum ecrecover: Ethereum
expects the secp256k1 group, then derives the address from the recovered
secp256k1 public key.
The security target is roughly the same: about 128-bit classical security. The
difference is operational. P-256 won the platform-authenticator ecosystem
because it was already standardized, widely certified, and implemented in secure
hardware. secp256k1 won the crypto-wallet ecosystem because Bitcoin picked it,
Ethereum inherited that account model, and the EVM exposes ecrecover for that
curve.
A key or signature from one curve cannot be reinterpreted on the other. A P-256 public key is not a secp256k1 public key; a P-256 ECDSA signature cannot recover an Ethereum address; and Ethereum address derivation expects the Keccak hash of a secp256k1 public key. Even if a WebAuthn registry can name secp256k1 as a COSE algorithm, a normal passkey assertion still signs WebAuthn bytes, not a native Ethereum transaction.
RS256 is RSA PKCS#1 v1.5 with SHA-256. The key material is a large RSA
modulus and exponent: n and e. Verification checks a modular exponentiation
relation, not an elliptic-curve point multiplication. Ethereum can perform
modular exponentiation through the EIP-198 precompile, so RSA verification is
possible in principle. It is a poor wallet primitive: larger keys, larger
inputs, heavier verification, and little account-wallet ecosystem momentum.
Native Ethereum accounts were designed for secp256k1:
ecrecover recovers the public key and therefore the EOA address.ES256 misses that native path on three points: the curve is P-256, the public
key has no Ethereum address derivation, and the WebAuthn signature wraps
browser metadata around the challenge. RS256 fits even less naturally because
RSA has no ecrecover-style account recovery story. A native EOA transaction
expects a secp256k1 signature; ES256 and RS256 need contract or account-level
verification.
Passkeys therefore enter Ethereum through account logic. ERC-4337 accounts, EIP-7702 delegation, and Safe-style modules can define a validator that accepts WebAuthn assertions.
An AA wallet does not turn the passkey into an EOA key. It stores the passkey credential as an account permission, then asks the account contract whether a WebAuthn assertion is valid for the operation being submitted.
Registration records the credential ID, P-256 public key, RP ID hash, and
account policy. When the user acts, the wallet hashes an
ERC-4337 UserOperation, an
ERC-1271 message, or an
account-specific request, then places that hash inside the WebAuthn challenge.
The browser returns authenticatorData, clientDataJSON, and a signature. The
account validator checks that WebAuthn wrapper, verifies the P-256 signature
against the stored passkey public key, and then applies account policy. That is
why the passkey controls the wallet without becoming the wallet's native chain
key: the browser sees WebAuthn; the chain sees account-level validation.
For an ERC-4337 account, the hook is validateUserOp. For dapps that verify a
smart-wallet signature, the hook is often ERC-1271 isValidSignature. Both can
delegate to the same passkey validator:
// Browser side: bind the account operation to the WebAuthn challenge.
const userOpHash = await entryPoint.getUserOpHash(userOp)
const assertion = await navigator.credentials.get({
publicKey: {
challenge: userOpHash,
userVerification: 'required',
allowCredentials: [{ type: 'public-key', id: credentialId }],
},
})
userOp.signature = encodeWebAuthnAssertion(assertion)
await bundler.sendUserOperation(userOp)The Solidity side is easier to read when split by responsibility. The snippets
below belong to the same validator contract. Production code still needs a real
clientDataJSON parser, origin checks, replay protection, and chain-specific
domain separation. P256.verify below means a RIP-7212-style precompile path or
a verifier library, not native Ethereum ecrecover.
Stored permission. Registration stores the WebAuthn relying-party hash and the passkey public key. The signature payload later carries the WebAuthn assertion fields.
bytes32 public rpIdHash;
bytes32 public qx;
bytes32 public qy;
struct WebAuthnSig {
bytes authenticatorData;
bytes clientDataJSON;
bytes32 r;
bytes32 s;
}ERC-4337 entry. A bundler submits a UserOperation; the account validator
receives the userOpHash and checks whether the passkey assertion authorized
that exact hash.
function validateUserOp(
bytes32 userOpHash,
WebAuthnSig calldata sig
) external view returns (bool) {
return _validatePasskey(userOpHash, sig);
}ERC-1271 entry. Dapps that ask a smart wallet to validate a message can use
the same validator through isValidSignature.
function isValidSignature(
bytes32 messageHash,
WebAuthnSig calldata sig
) external view returns (bytes4) {
return _validatePasskey(messageHash, sig)
? bytes4(0x1626ba7e)
: bytes4(0xffffffff);
}WebAuthn core check. This is the boundary where browser-scoped WebAuthn
bytes become account authorization. First bind the clientDataJSON.challenge to
the account operation, then rebuild the WebAuthn digest and verify it against
the stored P-256 public key.
function _validatePasskey(
bytes32 expectedChallenge,
WebAuthnSig calldata sig
) internal view returns (bool) {
if (!WebAuthn.challengeMatches(sig.clientDataJSON, expectedChallenge)) {
return false;
}
if (_rpIdHash(sig.authenticatorData) != rpIdHash) return false;
if (!_userPresentAndVerified(sig.authenticatorData)) return false;
bytes32 clientHash = sha256(sig.clientDataJSON);
bytes32 webAuthnDigest = sha256(
bytes.concat(sig.authenticatorData, clientHash)
);
return P256.verify(webAuthnDigest, sig.r, sig.s, qx, qy);
}Authenticator-data helpers. authenticatorData starts with the RP ID hash.
The flags byte follows at offset 32; bit 0 means user presence and bit 2
means user verification.
function _rpIdHash(
bytes calldata authenticatorData
) internal pure returns (bytes32 out) {
if (authenticatorData.length < 37) return bytes32(0);
assembly {
out := calldataload(authenticatorData.offset)
}
}
function _userPresentAndVerified(
bytes calldata authenticatorData
) internal pure returns (bool) {
if (authenticatorData.length < 37) return false;
uint8 flags = uint8(authenticatorData[32]);
return (flags & 0x01) != 0 && (flags & 0x04) != 0;
}The important part is not the interface name. It is the boundary: WebAuthn proves that the passkey signed browser-scoped bytes; account logic decides whether those bytes authorize a chain operation.
RIP-7212 changes the economics by adding a cheap P-256 verification path. OP-Stack Fjord and Polygon Napoli are examples of that direction. This does not make passkeys native Ethereum keys; it only makes the curve check practical inside account logic.
The precompile answers one narrow question: is this P-256 signature valid for this digest and public key? Challenge binding, RP ID, origin, nonces, sessions, and recovery remain wallet design.
// Curve check only. Production code still validates the WebAuthn wrapper.
address constant P256VERIFY = 0x0000000000000000000000000000000000000100;
function verifyP256(
bytes32 digest,
bytes32 r,
bytes32 s,
bytes32 Qx,
bytes32 Qy
) internal view returns (bool ok) {
bytes memory input = abi.encodePacked(digest, r, s, Qx, Qy);
uint256 success;
assembly {
success := staticcall(gas(), P256VERIFY, add(input, 0x20), mload(input), 0, 0)
}
return success == 1;
}Solana transactions require Ed25519 signatures. Mainstream browser passkeys mostly give wallets ES256, so Solana integrations usually treat passkeys as authorization for a separate signer:
passkey assertion -> session policy -> Ed25519 signer -> Solana transactionThat signer might be MPC, TEE-backed, hosted, app-controlled, or implemented inside another account abstraction layer. The security review moves to session scope, duration, spending limits, custody, recovery, and how the Ed25519 key is protected.
These projects differ mostly by where the passkey stops and where chain-native authorization begins.
EVM smart-account path
Cheap P-256 verification path
Non-EVM authorization path
The useful mental model is simple: passkeys are not chain-native private keys. They are origin-bound WebAuthn credentials that wallet or account logic can choose to trust.