@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:
- A global session key with full control of the account. Functionally, this is how you add another owner.
- A session key restricted to calling
execute/executeBatchonly — 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/validUntilare unix timestamps with a maximum size of uint32validUntilmust be strictly greater thanvalidAfter
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
executeandexecuteBatch. - Check order: wildcard address → wildcard function → exact address+function match → revert.
ERC-20 spend-limit details:
- Only
transferandapproveare allowed for tracked tokens. (The module is intentionally restrictive to avoid edge cases like DAI's non-standard methods.) - Works with
execute,executeBatch,executeWithRuntimeValidation, andexecuteUserOp.
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.SingleSignerValidationModuleprovides ECDSA validation;WebauthnModuleprovides 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 entity0, so start session keys at1.isGlobal— iftrue, the validation can call any function on the account. Recommendedfalseplus aselectorsarray. A global key has authority to upgrade the account implementation, which can change ownership in the same transaction.isSignatureValidation— iftrue, the key can produce ERC-1271 signatures (used for things like Permit2). Recommendedfalseunless you need it.isUserOpValidation— iftrue, the key can sign user operations. For most use cases this should betrue.
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) orHookType.EXECUTION(runs pre- and/or post-execution).hasPreHooks— runs before validation/execution.hasPostHooks— runs after validation/execution.