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. Author a SessionPolicy config: the allowlists and spend limits.
  2. Bind it to the account with defineSessionPolicy to get the commitment, the authorizeActor policy, and the install call.
  3. Authorize + install the key in one transaction. The install initializes the binding and MUST land before the key's first use.
  4. Use the key: it sends an executeCall, and its only reachable target is the manager.

Author + bind

defineSessionPolicyts
import { parseUnits } from 'viem'
import {
defineSessionPolicy,
encodeSessionPolicyConfig,
} from 'viem/experimental/eip8130'
const session = defineSessionPolicy({
account: account.address,
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 })

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 + install

Authorize the session key with a restricted scope (a policy-bearing actor must not be admin) and ride the install call in the same transaction. Read sequence with getConfigSequence8130 first.

Authorize + install in one txts
import {
actorScope,
authorizeActor,
key,
sendCalls8130,
} from 'viem/experimental/eip8130'
const sessionKey = key.p256({ x, y })
const change = await account.change(
[
authorizeActor(sessionKey, {
scope: actorScope.sender,
policy: session.actorPolicy,
}),
],
{ chainId: client.chain.id, sequence: Number(sequence) },
)
const hash = await sendCalls8130(client, {
account,
accountChanges: [change],
// The install initializes the binding; it MUST land before first use.
calls: [session.installCall(sessionKey.actorId)],
gas: 300_000n,
})

Use the session key

Build the account with the session signer and send an executeCall. The manager verifies the action against the committed policy, then executes it. A transfer that exceeds the limit, or targets a contract/selector outside the allowlist, reverts inside the manager, so the key can only ever act within its committed policy.

Spend within policyts
import { encodeFunctionData, erc20Abi, parseUnits } from 'viem'
import {
encodeSessionPolicyAction,
newSmartAccount8130,
sendCalls8130,
toP256Signer,
} from 'viem/experimental/eip8130'
// Same account address, driven by the session key.
const sessionAccount = newSmartAccount8130({
signer: toP256Signer({ privateKey: sessionPrivateKey }),
salt: accountSalt,
})
const transfer = encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [recipient, parseUnits('10', 6)],
})
const hash = await sendCalls8130(client, {
account: sessionAccount,
calls: [
session.executeCall(
encodeSessionPolicyAction({ target: usdc, data: transfer }),
),
],
gas: 250_000n,
})

Subscriptions

A subscription is just a session key whose policy encodes a per-period limit (for example 4.99 USDC every 30 days). Because the key holds its own nonce lane, the merchant does not coordinate a shared account nonce: when a charge is due it fires the transaction on a nonce-free or dedicated channel. There is no per-charge approval, and cancelling is a single revokeActor change.

Fire a recurring charge (no nonce coordination)ts
import { encodeFunctionData, erc20Abi, parseUnits } from 'viem'
import {
encodeSessionPolicyAction,
newSmartAccount8130,
nonce,
sendCalls8130,
toP256Signer,
} from 'viem/experimental/eip8130'
const sessionAccount = newSmartAccount8130({
signer: toP256Signer({ privateKey: sessionPrivateKey }),
salt: accountSalt,
})
const charge = encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [merchant, parseUnits('4.99', 6)],
})
// No nonce to read or reserve, just fire when the charge is due.
const hash = await sendCalls8130(client, {
account: sessionAccount,
calls: [
session.executeCall(
encodeSessionPolicyAction({ target: usdc, data: charge }),
),
],
gas: 250_000n,
...nonce.nonceless({ expiresIn: 600 }),
})

See Nonces & throughput for channel selection and Protocol · Subscriptions for the rationale.

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-only and cannot touch account ETH, so it relies on a sponsor. Add 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 that accepts spend-limit gas. See Gas & payers.