> ## Documentation Index
> Fetch the complete documentation index at: https://docs.monad.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# How to create passkey accounts with Mera

> Create and recover Monad accounts from a passkey, with no seed phrase.

[mera](https://mera.category.xyz/) derives EVM accounts from a passkey. Users create an account with Face ID, Touch ID, or a security key, and the same passkey reproduces the same accounts on every sign-in.

The accounts are regular EOAs. There is nothing to deploy, and no bundler or MPC service to run. Once you have an address, the rest is ordinary viem against a Monad RPC.

<Note>
  **Try the demo first**

  It is recommended to check that the [demo](https://mera-demo.up.railway.app) works with your passkey provider on the browsers and devices you plan to support. The [Authenticator support](https://mera.category.xyz/authenticator-support/) page lists the combinations that have been tested.
</Note>

## Install

```bash theme={null}
npm install @category-labs/mera viem @scure/bip32 @scure/bip39
```

## Requirements

* HTTPS, or `localhost` in development.
* A passkey provider that supports the WebAuthn PRF extension, such as iCloud Keychain, 1Password, or Google Password Manager. The [Authenticator support](https://mera.category.xyz/authenticator-support/) page has the full list.

<Warning>
  **Desktop Chrome**

  On desktop Chrome, only passkeys saved to Google Password Manager return PRF. When Chrome saves to the local profile instead, the passkey is created but mera throws `PRF_UNAVAILABLE`. This is the most common setup failure.
</Warning>

## Create a passkey

`createPasskeyWithPrfOutput` prompts the user to create a passkey and returns 32 secret bytes.

```ts theme={null}
import { createPasskeyWithPrfOutput } from "@category-labs/mera";

const created = await createPasskeyWithPrfOutput({
  rp: { id: location.hostname, name: "My Monad App" },
  user: { name: "player@example.com", displayName: "Player One" },
});

// created.prfOutput — Uint8Array(32), the root of every account
localStorage.setItem(
  "app.credential",
  JSON.stringify({
    credentialId: created.credentialId,
    transports: created.transports,
  }),
);
```

Every call creates a new passkey, and each passkey produces a different set of accounts. Run this once during onboarding and store the returned `credentialId`. Returning visits use `getPasskeyPrfOutput` with that credential, which is covered in [Signing in](#signing-in).

The credential metadata holds no key material, so `localStorage` is fine for it. Passing it back on the next sign-in lets the browser go straight to the same passkey rather than asking the user to pick one.

## Derive a key

The 32 bytes returned by the passkey, `prfOutput`, can be used to derive a standard BIP-44 account. Deriving it this way keeps the account portable, so an exported mnemonic imports into MetaMask or Rabby and produces the same address.

```ts theme={null}
import { HDKey } from "@scure/bip32";
import { entropyToMnemonic, mnemonicToSeedSync } from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english";

function deriveEvmKey(prfOutput: Uint8Array, index = 0): Uint8Array {
  const seed = mnemonicToSeedSync(entropyToMnemonic(prfOutput, wordlist));
  const node = HDKey.fromMasterSeed(seed).derive(`m/44'/60'/0'/0/${index}`);
  if (node.privateKey === null) throw new Error("derivation produced no key");
  return node.privateKey;
}
```

Increment `index` for additional accounts from the same passkey.

## Send a transaction

A signing session holds the key, and `toViemAccount` wraps it as a viem `LocalAccount`. It behaves like any other viem account and supports the same signing methods.

```ts theme={null}
import { createSecp256k1SigningSession } from "@category-labs/mera";
import { toViemAccount } from "@category-labs/mera/viem";
import { createWalletClient, http, parseEther } from "viem";
import { monadTestnet } from "viem/chains";

const session = createSecp256k1SigningSession({
  privateKey: deriveEvmKey(created.prfOutput),
});

const client = createWalletClient({
  account: toViemAccount(session),
  chain: monadTestnet,
  transport: http(),
});

const hash = await client.sendTransaction({
  to: "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
  value: parseEther("0.01"),
  gas: 21_000n,
});

session.end();
```

`end()` zeroes the key and cannot be undone, so the next transaction needs a new session. How long to keep one open is covered in [Putting it together](#putting-it-together).

Use `monad` instead of `monadTestnet` for mainnet. Both ship in `viem/chains`.

| Network       | Chain ID | viem export    |
| ------------- | -------- | -------------- |
| Monad         | 143      | `monad`        |
| Monad Testnet | 10143    | `monadTestnet` |

<Tip>
  **Gas**

  Monad charges the `gasLimit` you declare, not the gas used. Pass an explicit `gas` value, such as `21_000n` for a native transfer, instead of letting an estimate stand. See [Gas pricing](/developer-essentials/gas-pricing).
</Tip>

## Signing in

```ts theme={null}
import { getPasskeyPrfOutput } from "@category-labs/mera";

const stored = localStorage.getItem("app.credential");
const known = stored ? JSON.parse(stored) : undefined;

const { prfOutput, credentialId } = await getPasskeyPrfOutput({
  rpId: location.hostname,
  credential: known,
});

// Nothing stored? The browser may have used a different passkey, so record it.
localStorage.setItem(
  "app.credential",
  JSON.stringify(known?.credentialId === credentialId ? known : { credentialId }),
);
```

With a fresh device, `getPasskeyPrfOutput` will prompt the authenticator in a discoverable mode, and the user should be able to choose a previously saved passkey.

## Putting it together

There are two ways to arrange this, and the difference is how long the key stays in memory.

### Hold the session

Prompt the authenticator once, derive the key, and keep the session for as long as the user is signed in. This suits high-frequency workflows like trading, where a prompt per transaction is not workable. The key is in page memory for the whole session, and any script on the page can sign with it while it is live.

```ts theme={null}
// src/lib/wallet.ts
import {
  createSecp256k1SigningSession,
  getPasskeyPrfOutput,
  type Secp256k1SigningSession,
} from "@category-labs/mera";
import { toViemAccount } from "@category-labs/mera/viem";
import { createWalletClient, http, parseEther, type Hex } from "viem";
import { monadTestnet } from "viem/chains";

let session: Secp256k1SigningSession | undefined;

export async function connect() {
  const stored = localStorage.getItem("app.credential");
  const { prfOutput } = await getPasskeyPrfOutput({
    rpId: location.hostname,
    credential: stored ? JSON.parse(stored) : undefined,
  });

  session = createSecp256k1SigningSession({
    privateKey: deriveEvmKey(prfOutput),
  });

  return toViemAccount(session).address;
}

export function send(to: Hex, mon: string) {
  if (!session) throw new Error("not connected");

  const client = createWalletClient({
    account: toViemAccount(session),
    chain: monadTestnet,
    transport: http(),
  });

  return client.sendTransaction({ to, value: parseEther(mon), gas: 21_000n });
}

export function disconnect() {
  session?.end();
  session = undefined;
}
```

Call `disconnect()` on sign-out, and consider calling it on an idle timeout as well.

### Prompt per transaction

Run the ceremony for each transaction and end the session as soon as it is sent. The key exists only for the duration of the send, at the cost of a passkey prompt every time.

```ts theme={null}
export async function send(to: Hex, mon: string) {
  const stored = localStorage.getItem("app.credential");
  const { prfOutput } = await getPasskeyPrfOutput({
    rpId: location.hostname,
    credential: stored ? JSON.parse(stored) : undefined,
  });

  using session = createSecp256k1SigningSession({
    privateKey: deriveEvmKey(prfOutput),
  });

  const client = createWalletClient({
    account: toViemAccount(session),
    chain: monadTestnet,
    transport: http(),
  });

  return client.sendTransaction({ to, value: parseEther(mon), gas: 21_000n });
}
```

`using` ends the session when the scope exits. If your build target does not support explicit resource management, call `session.end()` in a `finally` block instead.

## Errors

mera throws `MeraError` with a `code`. Use the code to identify the error, since message text can change between versions.

```ts theme={null}
import { isMeraError } from "@category-labs/mera";

if (isMeraError(error)) {
  switch (error.code) {
    case "PRF_UNAVAILABLE":          // unsupported passkey provider
    case "PASSKEY_OPERATION_FAILED": // prompt dismissed or failed; offer a retry
    case "CRYPTO_UNAVAILABLE":       // not a secure context
    case "SESSION_ENDED":            // signing call after end()
  }
}
```

## Notes

* A passkey is tied to the `rpId` it was created for. If the app moves to a new domain, the accounts can no longer be derived, and users would need the 24-word mnemonic to recover them.
* Everything above derives the key from the passkey. When the key comes from somewhere else, such as a recovery phrase the user already has, [secret vaults](https://mera.category.xyz/concepts/secret-vaults/) encrypt it once and decrypt it with the passkey.
* `createEd25519SigningSession` and `getSolanaAddress` derive Solana accounts from the same passkey.

## Further resources

You can check out the [mera docs](https://mera.category.xyz/) for more details. The project is open source, so you can also [check out the code on GitHub](https://github.com/category-labs/mera).
