Guide

Session keys

A session key is a policy-gated actor: a key authorized with a restricted scope and a policy commitment. The protocol forces every call it makes onto a PolicyManager, which enforces spend limits and allowlists, then drives the account. Hand out a scoped key without granting ownership.

The flow

  1. Create a smart account (sponsored create bumps the local config sequence to 1).
  2. Author + bind a SessionPolicy with defineSessionPolicy.
  3. Authorize the PolicyManager (trusted executor) and the session key in one signed actor change on the local channel, at the live sequence. No install step.
  4. Use the key via PolicyManager.execute(binding, action). Declare scope only at authorize; do not redeclare it on the session account handle.
Account policies flow: a policy-scoped caller invokes the PolicyManager, which reads the acting actorId from the tx-context precompile, fetches the actor's policy_commitment from the Keystore, calls the committed policy after verifying the commitment, receives the call plan to execute, and calls executeBatch on the account. The account checks the caller holds TRUSTED_EXECUTOR before executing.
What happens under the hood when a session key spends: the PolicyManager resolves the acting actor, verifies the committed policy, and drives the account through a trusted executor.

Author + bind

defineSessionPolicyts
import { parseUnits } from 'viem'
import {
defineSessionPolicy,
encodeSessionPolicyConfig,
getEip8130Deployment,
} from 'viem/experimental/eip8130'
const deployment = getEip8130Deployment(client.chain.id)!
const { policies } = deployment
const session = defineSessionPolicy({
account: account.address,
policy: policies.sessionPolicy,
manager: policies.manager,
policyConfig: encodeSessionPolicyConfig({
// ≤ 100 USDC per week.
tokenLimits: [
{ token: usdc, limit: parseUnits('100', 6), period: 7n * 86_400n },
],
// Only transfer(address,uint256) on the USDC contract.
callScopes: [
{ target: usdc, selectorRules: [{ selector: '0xa9059cbb' }] },
],
}),
})
session.commitment // the policy commitment stored on the actor
session.actorPolicy // pass to authorizeActor(key, { scope, policy })
// No install — the full PolicyBinding is passed at execute time.

Selector rules may bind recipients for the standard ERC-20 selectors (transfer, transferFrom, approve):

Recipient allowlistts
import { encodeSessionPolicyConfig } from 'viem/experimental/eip8130'
encodeSessionPolicyConfig({
callScopes: [
{
target: usdc,
// recipients bind the standard ERC-20 selectors (transfer/transferFrom/approve).
selectorRules: [{ selector: '0xa9059cbb', recipients: [payroll] }],
},
],
})

Authorize

Register the PolicyManager as a trusted executor and the session key with a restricted scope in one change. Always grant NONCE for session keys so they use a sequenced channel (POLICY | NONCE). Do not start from nonceless for the primary path. Read the live local sequence with getConfigSequence8130 immediately before signing. Never hardcode it. On hosted Vibenet, the payer co-signs with mode: 'sign'; broadcast with the public RPC (https://rpc.vibes.base.org).

Authorize manager + session key (no install)ts
import {
actorScope,
authorizeActor,
getConfigSequence8130,
key,
} from 'viem/experimental/eip8130'
import { createPayerClient, sendSponsoredCalls } from 'viem/experimental/eip8168'
const sessionKey = key.k1(sessionSigner.address)
const manager = key.trustedExecutor(policies.manager)
// Always grant NONCE for session keys (sequenced channel). Declare scope ONCE
// at authorize — do not redeclare it on the session account handle later.
// Optional: OR actorScope.selfPayer to pay gas from account ETH.
const sessionScope = actorScope.policy | actorScope.nonce
// Read the LIVE local sequence — never hardcode it.
const { local } = await getConfigSequence8130(client, {
account: account.address,
accountConfiguration: deployment.accountConfiguration,
})
// Authorize manager + session key in ONE change. No install step.
const change = await account.change(
[
authorizeActor(manager, { scope: actorScope.sender }),
authorizeActor(sessionKey, {
scope: sessionScope,
expiry,
policy: session.actorPolicy,
}),
],
{ chainId: client.chain.id, sequence: Number(local) },
)
// Hosted Vibenet payer co-signs and broadcasts (default mode: 'send').
const payer = createPayerClient({
url: 'https://vibes.base.org/api/vibenet/account/payer',
})
const { transactionHash: hash } = await sendSponsoredCalls(client, {
account,
payerClient: payer,
accountChanges: [change],
calls: [{ to: account.address, value: 0n, data: '0x' }],
gas: 2_000_000n, // floor: payer estimates from calls only
context: { flow: 'transact' },
})
// If the tx lands, account changes applied (invalid changes cannot land).
// Prefer isActor8130 / getActorConfig8130 to confirm; poll briefly if needed.

Use the session key

Just use it. Build a session account handle with signer + address only (no redeclared scope: prepare reads on-chain getActorConfig and picks nonce mode from chain truth). Send executeCall; the full binding rides with the call. The manager verifies the action against the committed policy, then executes it.

Spend within policyts
import {
canonicalAuthenticators,
to8130Account,
} from 'viem/experimental/eip8130'
// Signer + address only — do NOT redeclare scope on this handle.
// prepare reads getActorConfig; with POLICY|NONCE it uses a sequenced nonce.
const sessionAccount = to8130Account({
signer: sessionSigner,
address: account.address,
authenticator: canonicalAuthenticators.k1,
accountConfigAddress: deployment.accountConfiguration,
})
const executeCall = session.executeCall({
target: usdc,
value: 0n,
data: transferData,
})
const { transactionHash: hash } = await sendSponsoredCalls(client, {
account: sessionAccount,
payerClient: payer,
calls: [executeCall],
gas: 2_000_000n,
context: { flow: 'transact' },
})

Subscriptions

A subscription is a policy-gated spend with a per-period limit (for example 4.99 USDC every 30 days). Two shapes:

  1. Session key on the account: authorize with POLICY | NONCE and fire each charge as an 8130 tx on its sequenced channel. No shared-nonce coordination across merchants.
  2. External caller: each account grants the service address as an EXTERNAL_POLICY_AUTHENTICATOR actor. The service then calls executeForMany as a plain EVM transaction and pulls from many accounts in one batch, with per-account revert isolation.
Recurring charge (session-key path)ts
import { encodeFunctionData, erc20Abi, parseUnits } from 'viem'
const charge = encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [merchant, parseUnits('4.99', 6)],
})
// Same session key (POLICY|NONCE): sequenced channel, not nonceless.
// External-caller batch pulls use executeForMany instead (see Protocol).
const executeCall = session.executeCall({
target: usdc,
value: 0n,
data: charge,
})

See Nonces & throughput for channel selection and Protocol · External caller flow for the batch-subscription path.

Read remaining budget

Render a live budget view for a policy-gated key with getSessionSpend8130, which combines the configured limit and current-period spend for a token.

getSessionSpend8130ts
import { getSessionSpend8130 } from 'viem/experimental/eip8130'
const { allowance, spent, remaining, periodEnd } = await getSessionSpend8130(
client,
{ commitment: session.commitment, token: usdc },
)

Paying for gas

A session key is typically POLICY | NONCE and relies on a sponsor for gas. OR SELF_PAYER only as a deliberate opt-in (a griefing key could then burn the balance on fees), or use an ERC-8168 payer service. See Gas & payers.