Use hardware keys everywhere!

November 02, 2024 / 10 min read / - views​

I used to think seed phrases were a necessary evil. Then 2024–2025 happened: passkeys went mainstream, rollups shipped secp256r1 precompiles, and wallets started leaning into hardware‑backed auth. The result? You can finally build Web3 flows that don’t crumble the moment someone clicks the wrong shiny button.

This guide is a practical playbook. I’ll show you how I integrate YubiKeys and platform passkeys, wire WebAuthn into a Next.js dApp, gate risky actions with step‑up auth, and use passkeys as smart‑account signers where networks support it. Then we’ll harden team ops: SSH, Git signing, deploys, multisigs, simulation, and allowance hygiene. No hand‑wavy vibes — just stuff that actually survives the internet.


Why now: what changed in 2024–2025

  • Passkeys everywhere. iOS, Android, Windows, macOS, Chrome, Safari, Edge. Users already have platform authenticators, and external keys like YubiKey ride the same WebAuthn rails.
  • RIP‑7212/EIP‑7951 precompiles. Many L2s added fast secp256r1 verification, which unlocks P‑256 passkey signatures on‑chain for account‑abstraction wallets. Translation: a passkey can be your wallet signer on supported chains.
  • Wallet + infra support. Smart‑account SDKs (Safe, ZeroDev, others) and services (Turnkey, etc.) ship passkey signers. WalletConnect/Web3Modal has clean SIWE flows. Security tooling matured: simulation, revoke tools, better EIP‑712 UX.

If you only take one thing: origin‑bound hardware credentials cut phishing to the knees. Bind critical actions to WebAuthn and you remove entire classes of scams.


Quick mental model: WebAuthn, passkeys, and where YubiKeys fit

  • WebAuthn is the browser API. Passkeys are the credentials. The private key lives in a secure element (hardware key like YubiKey) or platform TPM/SE. The public key + metadata sits in your DB.
  • Origin binding. A passkey created for example.com simply won’t sign for evil‑example.com. That’s the phishing‑resistance you want.
  • Device vs synced. Platform passkeys can sync via iCloud/Google Password Manager; a YubiKey is portable and works across machines, great for devs and high‑risk users.
  • Ethereum signing reality. Classic EOAs use secp256k1; YubiKeys don’t natively hold k1 for wallets. You either use a hardware wallet (Ledger/Trezor) for EOAs, or you adopt account abstraction with P‑256 verification on chains that support it, so a passkey/YubiKey becomes a first‑class signer.

Two secure patterns for dApps

1) Passkey for app identity + wallet for on‑chain actions

Use WebAuthn to authenticate the user to your app, then link a wallet using SIWE (EIP‑4361). You gate dangerous operations with a step‑up passkey challenge before you even open a wallet prompt.

Good for: every dApp today. No network constraints. Massive phishing reduction.

2) Passkey as the smart‑account signer (AA)

On L2s with RIP‑7212/EIP‑7951 you can verify secp256r1 cheaply on‑chain. Smart‑account frameworks expose passkey signers that plug into Safe/Kernel/others. You can require a second factor or timelock for large transfers.

Good for: consumer wallets with smooth UX, enterprise policies, mobile‑first flows. Not universal yet; check chain support.


Implement WebAuthn in a Next.js/Express stack

We’ll use SimpleWebAuthn because it’s well‑maintained and TypeScript‑first.

Server: registration and authentication routes (Express + TypeScript)

// server/webauthn.ts
import express from 'express';
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
  type VerifiedRegistrationResponse,
  type VerifiedAuthenticationResponse,
} from '@simplewebauthn/server';
import type { RegistrationResponseJSON, AuthenticationResponseJSON } from '@simplewebauthn/types';
 
const router = express.Router();
 
// Replace with your domain
const rpID = 'example.com';
const origin = `https://${rpID}`;
 
// naive in‑memory store; replace with a DB
const users = new Map<string, {
  id: string;
  username: string;
  credentials: Array<{
    id: string; // base64url
    publicKey: string; // base64url
    counter: number;
    transports?: string[];
  }>;
}>();
 
// Helper
function getUser(username: string) {
  let u = users.get(username);
  if (!u) {
    u = { id: crypto.randomUUID(), username, credentials: [] };
    users.set(username, u);
  }
  return u;
}
 
router.get('/registration-options', (req, res) => {
  const { username } = req.query as { username: string };
  const user = getUser(username);
 
  const options = generateRegistrationOptions({
    rpName: 'My dApp',
    rpID,
    userID: user.id,
    userName: user.username,
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'required',
      userVerification: 'required',
      authenticatorAttachment: 'cross-platform', // allow YubiKey & platform
    },
    excludeCredentials: user.credentials.map(c => ({ id: c.id, type: 'public-key', transports: c.transports })),
  });
 
  // stash challenge in session
  (req.session as any).currentChallenge = options.challenge;
  res.json(options);
});
 
router.post('/verify-registration', async (req, res) => {
  const { username } = req.query as { username: string };
  const user = getUser(username);
  const body = req.body as RegistrationResponseJSON;
 
  const verification: VerifiedRegistrationResponse = await verifyRegistrationResponse({
    response: body,
    expectedChallenge: (req.session as any).currentChallenge,
    expectedRPID: rpID,
    expectedOrigin: origin,
    requireUserVerification: true,
  });
 
  if (!verification.verified || !verification.registrationInfo) return res.status(400).json({ ok: false });
 
  const { credentialID, credentialPublicKey, counter, credentialDeviceType, credentialBackedUp, transports } =
    verification.registrationInfo;
 
  user.credentials.push({ id: credentialID, publicKey: credentialPublicKey, counter, transports });
  res.json({ ok: true });
});
 
router.get('/authentication-options', (req, res) => {
  const { username } = req.query as { username: string };
  const user = getUser(username);
 
  const options = generateAuthenticationOptions({
    rpID,
    allowCredentials: user.credentials.map(c => ({ id: c.id, type: 'public-key', transports: c.transports })),
    userVerification: 'required',
  });
  (req.session as any).currentChallenge = options.challenge;
  res.json(options);
});
 
router.post('/verify-authentication', async (req, res) => {
  const { username } = req.query as { username: string };
  const user = getUser(username);
  const body = req.body as AuthenticationResponseJSON;
 
  const dbCred = user.credentials.find(c => c.id === body.rawId);
  if (!dbCred) return res.status(400).json({ ok: false });
 
  const verification: VerifiedAuthenticationResponse = await verifyAuthenticationResponse({
    response: body,
    expectedChallenge: (req.session as any).currentChallenge,
    expectedRPID: rpID,
    expectedOrigin: origin,
    requireUserVerification: true,
    authenticator: {
      credentialID: dbCred.id,
      credentialPublicKey: dbCred.publicKey,
      counter: dbCred.counter,
      transports: dbCred.transports,
    },
  });
 
  if (!verification.verified || !verification.authenticationInfo) return res.status(400).json({ ok: false });
  dbCred.counter = verification.authenticationInfo.newCounter;
 
  // mark session as strongly authenticated
  (req.session as any).user = { id: user.id, username: user.username, weauthnLevel: 'uv' };
  res.json({ ok: true });
});
 
export default router;

Client: registration, login, and Conditional UI

// app/webauthn.ts
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
 
export async function registerPasskey(username: string) {
  const opts = await fetch(`/webauthn/registration-options?username=${encodeURIComponent(username)}`).then(r => r.json());
  const att = await startRegistration(opts);
  await fetch(`/webauthn/verify-registration?username=${encodeURIComponent(username)}`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(att),
  });
}
 
export async function loginWithPasskey(username: string, useAutofill = false) {
  const opts = await fetch(`/webauthn/authentication-options?username=${encodeURIComponent(username)}`).then(r => r.json());
  const assertion = await startAuthentication({ ...opts, useBrowserAutofill: useAutofill });
  await fetch(`/webauthn/verify-authentication?username=${encodeURIComponent(username)}`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(assertion),
  });
}

Tip: for smooth UX, enable Conditional UI (aka passkey autofill) on your username field and call startAuthentication({ useBrowserAutofill: true }) on focus. Browsers will surface passkeys alongside saved usernames.


Gate dangerous actions with step‑up WebAuthn

Add a lightweight middleware that checks req.session.user.weauthnLevel === 'uv' within the last N minutes before you:

  • show the Connect Wallet button
  • submit a transaction
  • change payout addresses
  • approve ERC‑20 spending over a threshold

This means even if a user lands on a convincing phishing page, their passkey simply won’t sign for it because the RP ID won’t match. You stop the attack before a wallet prompt appears.


  1. User connects a wallet (WalletConnect/Web3Modal, MetaMask, whatever your stack uses).
  2. Your backend issues a nonce and an EIP‑4361 SIWE message for your domain.
  3. User signs. You verify the message and signature off‑chain and bind it to their passkey account.
  4. Store an app session (cookie/JWT) with both identities linked.

Gotchas:

  • Always verify the domain and scheme fields. Don’t accept SIWE for a different origin.
  • Show the address and chain clearly and simulate downstream actions before requesting signatures.

Passkeys as signers for smart accounts

On networks that support P‑256 verification on‑chain, you can let users create a smart account controlled by a passkey.

Two common routes:

  • Native (on‑chain) P‑256: use AA SDKs that wrap RIP‑7212/EIP‑7951. Create a passkey, store its public key in the account’s validator, and verify assertions on‑chain for each UserOp.
  • Managed keys: services like Turnkey let users authenticate with a passkey and then derive an EVM key in an HSM, exposing a familiar EOA signer to your app.

Design notes

  • Start with a 2‑of‑2 or 2‑of‑3 policy: passkey + hardware wallet, plus a recovery key or guardian.
  • Enforce daily spend limits and timelocks; use session keys for bots/automations.
  • Communicate clearly that passkeys are domain‑scoped. Losing access to your domain without recovery plans can brick UX.

YubiKey in a Web3 engineer’s toolkit

1) SSH with FIDO2 resident keys

# Generate a hardware‑backed SSH key that requires touch and PIN
ssh-keygen -t ed25519-sk -O resident -O verify-required -C "dev-ssh"
# List resident keys and add to agent
ssh-keygen -K
ssh-add -K

2) Git commit signing with SSH keys

# Use SSH signing
git config --global gpg.format ssh
# Point to your SSH public key (ed25519-sk.pub or similar)
git config --global user.signingkey "$(cat ~/.ssh/id_ed25519_sk.pub)"
# Optional: sign by default
git config --global commit.gpgsign true

3) Lock down the FIDO2 app on the key

# Set or change the FIDO2 PIN
ykman fido access change-pin
# Require user verification for every operation
ykman fido config toggle-always-uv

4) PIV slots for org SSO or codesigning

# Generate ECC P‑256 key in slot 9a and require touch every use
ykman piv keys generate -a ECCP256 --touch-policy ALWAYS --pin-policy ONCE 9a ./yubi-9a.pub

This gives you a single, portable hardware root for SSH, Git, SSO, and strong WebAuthn on the web.


Phishing protection that actually works

  • Origin‑bound passkeys for login and step‑up checks.
  • EIP‑712 typed data only. No blind eth_sign prompts.
  • Simulate everything before asking for a signature. Run server‑side simulations in CI and show client‑side previews (Rabby, Wallet Guard, Tenderly, etc.).
  • Allowance hygiene: avoid unlimited approvals. Prefer Permit2 with expirations, and surface revoke UX after flows. Add an automated reminder for stale allowances.
  • Domain allow‑listing: hardcode your API and wallet endpoints, use HSTS and CSP, and block third‑party scripts where possible.

UX tip: show a big, unmissable summary like “Swap 1.23 ETH on Base to 4,567 USDC. Max spend 1.3 ETH. Recipient 0xABC…DEF.” If the wallet UI contradicts yours, stop.


Rollout checklist for teams

  • Choose target chains and confirm P‑256 precompile support if you plan passkey signers.
  • Wire WebAuthn registration/login with resident keys and UV required.
  • Add step‑up passkey before high‑risk actions.
  • Integrate SIWE with strict domain checks.
  • Add simulation and clear EIP‑712 copy.
  • Ship allowance management UX and a monthly revoke reminder.
  • Enforce hardware keys for SSH/Git across the team.
  • Define a recovery policy (guardians, social recovery, rotating owner keys, break‑glass).

Troubleshooting

  • "NotAllowedError" on Safari: WebAuthn calls must be triggered by a user gesture; on iOS/macOS Safari, make the network request for options inside the click handler.
  • RP ID mismatch: don’t test with raw IPs or mismatched subdomains; set rpID to your registrable domain.
  • Passkey not offered in autofill: ensure discoverable credentials (resident key) were requested on registration and Conditional UI is enabled for that input.
  • Counters never increase: some authenticators don’t bump counters. You can still treat any verified assertion as valid and use server time for replay windows.
  • YubiKey refusing operations: if you toggled “always‑UV,” you must enter the PIN or use biometrics every time. That’s the point.

Where I’d use which pattern

  • Exchanges, NFT mints, DeFi: Passkey login + SIWE + step‑up. Lowest friction, best compatibility.
  • Consumer wallet onboarding: Passkey smart accounts on L2s with P‑256 precompile. Pair with a second factor for high‑value activity.
  • Teams/DAOs: Safe with passkey owners + hardware wallet owners, spend limits, and timelocks. Enforce YubiKey for SSH/Git and SSO.

Hardware keys everywhere isn’t a slogan. It’s a blueprint for finally stopping the dumbest losses in Web3 without turning your UI into a torture device.