# Adding session keys to your Modular Account V2

> Adding session keys to your Modular Account V2

> For the complete documentation index, see [llms.txt](/docs/llms.txt).

<Tip title="Most projects should use @alchemy/wallet-apis">
  [`@alchemy/wallet-apis`](/docs/wallets/quickstart) supports session keys through [`wallet_createSession`](/docs/wallets/reference/wallet-apis-session-keys/api) and the [`grantPermissions`](/docs/wallets/reference/wallet-apis-session-keys/sdk) SDK flow. This page covers the lower-level path: installing Modular Account V2 session-key validations directly with `@alchemy/smart-accounts` and viem's bundler client. Use it when you need lower-level control for custom onchain validation and permission wiring.
</Tip>

To add a session key: 1) decide what permissions you want to grant the new key, and 2) call the [`installValidation`](#install-validation-parameters) function on the account. In v5 you do this by extending the bundler client with `installValidationActions`, encoding the install call via `encodeInstallValidation`, then sending it as a user operation. The bundler client returns a calldata hex; `sendUserOperation` wraps it in an `execute` call from the account.

Scoped permissions are layered on as **hooks** that you pass into `installValidation`. Hooks can be combined — e.g. a session key that can only spend 10 USDC within the next 24 hours from the account.

## Adding a global session key (i.e. an additional owner)

This example shows two variants:

1. A **global** session key with full control of the account. Functionally, this is how you add another owner.
2. A session key restricted to calling `execute` / `executeBatch` only — full control of assets but unable to change the account implementation.

```ts twoslash
import { createBundlerClient } from "viem/account-abstraction";
import { createClient, toFunctionSelector, getAbiItem, type Hex } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount, generatePrivateKey, mnemonicToAccount } from "viem/accounts";
import { alchemyTransport } from "@alchemy/common";
import {
  toModularAccountV2,
  installValidationActions,
  SingleSignerValidationModule,
  DefaultModuleAddress,
  semiModularAccountV2StaticImpl,
} from "@alchemy/smart-accounts";
import { estimateFeesPerGas } from "@alchemy/aa-infra";

const transport = alchemyTransport({ apiKey: "your-api-key" });
const rpcClient = createClient({ chain: sepolia, transport });

const account = await toModularAccountV2({
  client: rpcClient,
  owner: privateKeyToAccount(generatePrivateKey()),
});

const bundlerClient = createBundlerClient({
  account,
  client: rpcClient,
  chain: sepolia,
  transport,
  userOperation: { estimateFeesPerGas },
}).extend(installValidationActions);

const sessionKeySigner = mnemonicToAccount("SESSION_KEY_MNEMONIC");

// 1. Global session key (full permissions)
let sessionKeyEntityId = 1;
const installGlobalCallData: Hex = await bundlerClient.encodeInstallValidation({
  validationConfig: {
    moduleAddress: DefaultModuleAddress.SINGLE_SIGNER_VALIDATION,
    entityId: sessionKeyEntityId,
    isGlobal: true,
    isSignatureValidation: true,
    isUserOpValidation: true,
  },
  selectors: [],
  installData: SingleSignerValidationModule.encodeOnInstallData({
    entityId: sessionKeyEntityId,
    signer: sessionKeySigner.address,
  }),
  hooks: [],
});
await bundlerClient.sendUserOperation({ callData: installGlobalCallData });

// 2. Session key restricted to execute / executeBatch
sessionKeyEntityId = 2;
const executeSelector = toFunctionSelector(
  getAbiItem({ abi: semiModularAccountV2StaticImpl.accountAbi, name: "execute" }),
);
const executeBatchSelector = toFunctionSelector(
  getAbiItem({ abi: semiModularAccountV2StaticImpl.accountAbi, name: "executeBatch" }),
);

const installExecuteOnlyCallData: Hex = await bundlerClient.encodeInstallValidation({
  validationConfig: {
    moduleAddress: DefaultModuleAddress.SINGLE_SIGNER_VALIDATION,
    entityId: sessionKeyEntityId,
    isGlobal: false,
    isSignatureValidation: false,
    isUserOpValidation: true,
  },
  selectors: [executeSelector, executeBatchSelector],
  installData: SingleSignerValidationModule.encodeOnInstallData({
    entityId: sessionKeyEntityId,
    signer: sessionKeySigner.address,
  }),
  hooks: [],
});
await bundlerClient.sendUserOperation({ callData: installExecuteOnlyCallData });
```

## Adding a session key with permissions

### Time range

The Time Range Module enforces time-based validation. The example below adds a session key that's valid for the next two days.

Notes:

* the interval is inclusive: `[validAfter, validUntil]`
* `validAfter` / `validUntil` are unix timestamps with a maximum size of uint32
* `validUntil` must be **strictly greater than** `validAfter`

```ts twoslash
import { createBundlerClient } from "viem/account-abstraction";
import { createClient } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount, generatePrivateKey, mnemonicToAccount } from "viem/accounts";
import { alchemyTransport } from "@alchemy/common";
import {
  toModularAccountV2,
  installValidationActions,
  SingleSignerValidationModule,
  TimeRangeModule,
  DefaultModuleAddress,
} from "@alchemy/smart-accounts";
import { estimateFeesPerGas } from "@alchemy/aa-infra";

// Inline until `HookType` is value-exported from `@alchemy/smart-accounts`.
const HookType = { EXECUTION: "0x00", VALIDATION: "0x01" } as const;

const transport = alchemyTransport({ apiKey: "your-api-key" });
const rpcClient = createClient({ chain: sepolia, transport });

const account = await toModularAccountV2({
  client: rpcClient,
  owner: privateKeyToAccount(generatePrivateKey()),
});

const bundlerClient = createBundlerClient({
  account,
  client: rpcClient,
  chain: sepolia,
  transport,
  userOperation: { estimateFeesPerGas },
}).extend(installValidationActions);

const sessionKeySigner = mnemonicToAccount("SESSION_KEY_MNEMONIC");
const sessionKeyEntityId = 1;
const hookEntityId = 0; // must not collide with an existing hook entity id on the module
const validAfter = 0; // valid immediately
const validUntil = validAfter + 2 * 86400; // expires in 2 days

const callData = await bundlerClient.encodeInstallValidation({
  validationConfig: {
    moduleAddress: DefaultModuleAddress.SINGLE_SIGNER_VALIDATION,
    entityId: sessionKeyEntityId,
    isGlobal: true,
    isSignatureValidation: true,
    isUserOpValidation: true,
  },
  selectors: [],
  installData: SingleSignerValidationModule.encodeOnInstallData({
    entityId: sessionKeyEntityId,
    signer: sessionKeySigner.address,
  }),
  hooks: [
    {
      hookConfig: {
        address: DefaultModuleAddress.TIME_RANGE,
        entityId: hookEntityId,
        hookType: HookType.VALIDATION, // fixed
        hasPreHooks: true, // fixed
        hasPostHooks: false, // fixed
      },
      initData: TimeRangeModule.encodeOnInstallData({
        entityId: hookEntityId,
        validAfter,
        validUntil,
      }),
    },
  ],
});
await bundlerClient.sendUserOperation({ callData });
```

### Paymaster guard

Restricts a session key to using a single paymaster contract.

Notes:

* you **must** specify a paymaster contract when using this module
* if the registered paymaster stops sponsoring, the session key can no longer send transactions

```ts twoslash
import { createBundlerClient } from "viem/account-abstraction";
import { createClient } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount, generatePrivateKey, mnemonicToAccount } from "viem/accounts";
import { alchemyTransport } from "@alchemy/common";
import {
  toModularAccountV2,
  installValidationActions,
  SingleSignerValidationModule,
  PaymasterGuardModule,
  DefaultModuleAddress,
} from "@alchemy/smart-accounts";
import { estimateFeesPerGas } from "@alchemy/aa-infra";

// Inline until `HookType` is value-exported from `@alchemy/smart-accounts`.
const HookType = { EXECUTION: "0x00", VALIDATION: "0x01" } as const;

const transport = alchemyTransport({ apiKey: "your-api-key" });
const rpcClient = createClient({ chain: sepolia, transport });

const account = await toModularAccountV2({
  client: rpcClient,
  owner: privateKeyToAccount(generatePrivateKey()),
});

const bundlerClient = createBundlerClient({
  account,
  client: rpcClient,
  chain: sepolia,
  transport,
  userOperation: { estimateFeesPerGas },
}).extend(installValidationActions);

const sessionKeySigner = mnemonicToAccount("SESSION_KEY_MNEMONIC");
const sessionKeyEntityId = 1;
const hookEntityId = 0;
const paymasterAddress = "0xd8da6bf26964af9d7eed9e03e53415d37aa96045";

const callData = await bundlerClient.encodeInstallValidation({
  validationConfig: {
    moduleAddress: DefaultModuleAddress.SINGLE_SIGNER_VALIDATION,
    entityId: sessionKeyEntityId,
    isGlobal: true,
    isSignatureValidation: true,
    isUserOpValidation: true,
  },
  selectors: [],
  installData: SingleSignerValidationModule.encodeOnInstallData({
    entityId: sessionKeyEntityId,
    signer: sessionKeySigner.address,
  }),
  hooks: [
    {
      hookConfig: {
        address: DefaultModuleAddress.PAYMASTER_GUARD,
        entityId: hookEntityId,
        hookType: HookType.VALIDATION,
        hasPreHooks: true,
        hasPostHooks: false,
      },
      initData: PaymasterGuardModule.encodeOnInstallData({
        entityId: hookEntityId,
        paymaster: paymasterAddress,
      }),
    },
  ],
});
await bundlerClient.sendUserOperation({ callData });
```

### Native token and/or gas limit

Caps native-token spend (and optionally gas costs) for the session key. The example below installs a 1 ETH spend limit.

What this module covers:

* Tracks native-token spending across `execute`, `executeBatch`, `performCreate`, and UserOperation gas costs.
* Paymaster handling:
  * Standard paymasters — gas costs **don't** count against the limit.
  * "Special" paymasters — gas costs **do** count against the limit.
* Limits are per entity ID.

UserOperation gas tracked: pre-verification gas, verification gas, call gas, and (for special paymasters) paymaster verification + post-op gas.

Install notes:

* The module must be installed with both **validation** and **execution** hooks — the validation hook tracks gas, the execution hook tracks value.
* The module maintains global singleton state across all accounts.

```ts twoslash
import { createBundlerClient } from "viem/account-abstraction";
import { createClient, parseEther } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount, generatePrivateKey, mnemonicToAccount } from "viem/accounts";
import { alchemyTransport } from "@alchemy/common";
import {
  toModularAccountV2,
  installValidationActions,
  SingleSignerValidationModule,
  NativeTokenLimitModule,
  DefaultModuleAddress,
} from "@alchemy/smart-accounts";
import { estimateFeesPerGas } from "@alchemy/aa-infra";

// Inline until `HookType` is value-exported from `@alchemy/smart-accounts`.
const HookType = { EXECUTION: "0x00", VALIDATION: "0x01" } as const;

const transport = alchemyTransport({ apiKey: "your-api-key" });
const rpcClient = createClient({ chain: sepolia, transport });

const account = await toModularAccountV2({
  client: rpcClient,
  owner: privateKeyToAccount(generatePrivateKey()),
});

const bundlerClient = createBundlerClient({
  account,
  client: rpcClient,
  chain: sepolia,
  transport,
  userOperation: { estimateFeesPerGas },
}).extend(installValidationActions);

const sessionKeySigner = mnemonicToAccount("SESSION_KEY_MNEMONIC");
const sessionKeyEntityId = 1;
const hookEntityId = 0;

const callData = await bundlerClient.encodeInstallValidation({
  validationConfig: {
    moduleAddress: DefaultModuleAddress.SINGLE_SIGNER_VALIDATION,
    entityId: sessionKeyEntityId,
    isGlobal: true,
    isSignatureValidation: true,
    isUserOpValidation: true,
  },
  selectors: [],
  installData: SingleSignerValidationModule.encodeOnInstallData({
    entityId: sessionKeyEntityId,
    signer: sessionKeySigner.address,
  }),
  hooks: [
    {
      hookConfig: {
        address: DefaultModuleAddress.NATIVE_TOKEN_LIMIT,
        entityId: hookEntityId,
        hookType: HookType.VALIDATION,
        hasPreHooks: true,
        hasPostHooks: false,
      },
      initData: NativeTokenLimitModule.encodeOnInstallData({
        entityId: hookEntityId,
        spendLimit: parseEther("1"),
      }),
    },
    {
      hookConfig: {
        address: DefaultModuleAddress.NATIVE_TOKEN_LIMIT,
        entityId: hookEntityId,
        hookType: HookType.EXECUTION,
        hasPreHooks: true,
        hasPostHooks: false,
      },
      initData: "0x", // no init data — the limit was set above on the validation hook
    },
  ],
});
await bundlerClient.sendUserOperation({ callData });
```

### Allowlist or ERC-20 token limit

The allowlist module provides two features:

* **Address/function allowlisting** — controls which targets and selectors a session key can call.
* **ERC-20 spend limits** — caps ERC-20 spending per token.

Allowlist details:

* Permissions are matched per `(address, selector)`. Wildcards are supported on either side.
* Only applies to `execute` and `executeBatch`.
* Check order: wildcard address → wildcard function → exact address+function match → revert.

ERC-20 spend-limit details:

* Only `transfer` and `approve` are allowed for tracked tokens. (The module is intentionally restrictive to avoid edge cases like DAI's non-standard methods.)
* Works with `execute`, `executeBatch`, `executeWithRuntimeValidation`, and `executeUserOp`.

Other notes:

* The module is installed/uninstalled per entity ID — uninstalling one entity doesn't affect others.
* Settings are stored in a global singleton contract and can be updated dynamically.

```ts twoslash
import { createBundlerClient } from "viem/account-abstraction";
import { createClient, parseEther } from "viem";
import { sepolia } from "viem/chains";
import { privateKeyToAccount, generatePrivateKey, mnemonicToAccount } from "viem/accounts";
import { alchemyTransport } from "@alchemy/common";
import {
  toModularAccountV2,
  installValidationActions,
  SingleSignerValidationModule,
  AllowlistModule,
  DefaultModuleAddress,
} from "@alchemy/smart-accounts";
import { estimateFeesPerGas } from "@alchemy/aa-infra";

// Inline until `HookType` is value-exported from `@alchemy/smart-accounts`.
const HookType = { EXECUTION: "0x00", VALIDATION: "0x01" } as const;

const transport = alchemyTransport({ apiKey: "your-api-key" });
const rpcClient = createClient({ chain: sepolia, transport });

const account = await toModularAccountV2({
  client: rpcClient,
  owner: privateKeyToAccount(generatePrivateKey()),
});

const bundlerClient = createBundlerClient({
  account,
  client: rpcClient,
  chain: sepolia,
  transport,
  userOperation: { estimateFeesPerGas },
}).extend(installValidationActions);

const sessionKeySigner = mnemonicToAccount("SESSION_KEY_MNEMONIC");
const sessionKeyEntityId = 1;
const hookEntityId = 0;

const allowlistInstallData = AllowlistModule.encodeOnInstallData({
  entityId: hookEntityId,
  inputs: [
    {
      target: "0xd8da6bf26964af9d7eed9e03e53415d37aa96045",
      hasSelectorAllowlist: false, // whether to limit callable functions on this target
      hasERC20SpendLimit: false,   // whether `target` is an ERC-20 with a spend cap
      erc20SpendLimit: parseEther("100"), // spend cap if applicable
      selectors: [], // function selectors to allow if applicable
    },
  ],
});

const callData = await bundlerClient.encodeInstallValidation({
  validationConfig: {
    moduleAddress: DefaultModuleAddress.SINGLE_SIGNER_VALIDATION,
    entityId: sessionKeyEntityId,
    isGlobal: true,
    isSignatureValidation: true,
    isUserOpValidation: true,
  },
  selectors: [],
  installData: SingleSignerValidationModule.encodeOnInstallData({
    entityId: sessionKeyEntityId,
    signer: sessionKeySigner.address,
  }),
  hooks: [
    {
      hookConfig: {
        address: DefaultModuleAddress.ALLOWLIST,
        entityId: hookEntityId,
        hookType: HookType.VALIDATION,
        hasPreHooks: true,
        hasPostHooks: false,
      },
      initData: allowlistInstallData,
    },
  ],
});
await bundlerClient.sendUserOperation({ callData });
```

## Install validation parameters

Configurable parameters for `encodeInstallValidation`:

`validationConfig`:

* `moduleAddress` — address of the validation module. `SingleSignerValidationModule` provides ECDSA validation; `WebauthnModule` provides WebAuthn. For custom validation (multisig, etc.), use that module's address.
* `entityId` — uint32 identifier for the validation. Must not collide with an existing entity on the account. The owner is entity `0`, so start session keys at `1`.
* `isGlobal` — if `true`, the validation can call any function on the account. **Recommended `false`** plus a `selectors` array. A global key has authority to upgrade the account implementation, which can change ownership in the same transaction.
* `isSignatureValidation` — if `true`, the key can produce ERC-1271 signatures (used for things like Permit2). **Recommended `false`** unless you need it.
* `isUserOpValidation` — if `true`, the key can sign user operations. For most use cases this should be `true`.

`selectors` — function selectors the key may call. Ignored if `isGlobal` is `true`. If `isGlobal` is `false`, the key can **only** call selectors listed here. Recommended: `ModularAccount.execute.selector` and `ModularAccount.executeBatch.selector`.

`installData` — module-specific install data. Each module ships an `encodeOnInstallData` helper.

`hooks` — array of `{ hookConfig, initData }` to install alongside the validation.

`hookConfig`:

* `address` — hook module address.
* `entityId` — hook-module entity id (distinct from the validation entity id). Decoupled so multiple hooks from the same module can apply to one key. Must not collide with an existing hook entity on the account.
* `hookType` — `HookType.VALIDATION` (runs in validation phase) or `HookType.EXECUTION` (runs pre- and/or post-execution).
* `hasPreHooks` — runs before validation/execution.
* `hasPostHooks` — runs after validation/execution.