Skip to content
Alchemy Logo

Adding session keys to your Modular Account V2

Most projects should use @alchemy/wallet-apis

@alchemy/wallet-apis supports session keys through wallet_createSession and the grantPermissions 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.

To add a session key: 1) decide what permissions you want to grant the new key, and 2) call the installValidation 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.

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.
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 });

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
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 });

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
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 });

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.
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 });

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.
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 });

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.
  • hookTypeHookType.VALIDATION (runs in validation phase) or HookType.EXECUTION (runs pre- and/or post-execution).
  • hasPreHooks — runs before validation/execution.
  • hasPostHooks — runs after validation/execution.
Was this page helpful?