ByAUJay
Minimal-Trust Session Keys with EIP-7702: A Reference Implementation
Summary: This guide shows decision-makers and engineers how to ship minimal‑trust session keys on Ethereum using EIP‑7702 delegations, with a battle-tested security model, a compact reference contract, client snippets, and integration patterns that interoperate with ERC‑4337, ERC‑5792, and emerging 7702 tooling. It focuses on concrete implementation details, gas/UX trade‑offs, and production hardening steps you can apply this quarter.
Why this matters now
EIP‑7702 gives an EOA a way to permanently point to contract code via a new transaction type (0x04), letting legacy addresses gain “smart wallet” features like batching, sponsorship, and scoped sub‑keys—without migrating funds or approvals. The mechanism uses an authorization list to set a “delegation indicator” (
0xef0100 || address) as the EOA’s code, so calls to the EOA execute the target contract in the EOA’s context. That single change unlocks session keys that feel like OAuth scopes for onchain actions. (eips.ethereum.org)
As of October 22, 2025, Ethereum’s Pectra docs provide clear 7702 guidance, including how authorization tuples work, why
chain_id=0 delegates across all chains, and how to reset to a null delegation. They also outline best practices for relaying via ERC‑4337 bundlers and wallet interfaces. (ethereum.org)
What “minimal‑trust session keys” means
- Least authority: Each session key can only call specific contracts/functions, with per‑tx and per‑period spend caps, gas budgets, and strict expiries.
- Self‑sponsoring and sponsored modes: Works whether the user pays their own gas or a paymaster covers it; the policy lives onchain, not in a vendor server. (eips.ethereum.org)
- Zero “forever” approvals: Keys time out quickly and are revocable onchain.
- Wallet‑agnostic flows: Use ERC‑5792 (
) for batching and ERC‑4337 for sponsorship so you’re not tied to a single relayer. (ethereum.org)wallet_sendCalls
The protocol facts you should rely on
- Transaction type:
. Payload includesSET_CODE_TX_TYPE = 0x04
. Signature domain usesauthorization_list = [[chain_id, address, nonce, y_parity, r, s], ...]
. Each authorization message iskeccak256(0x04 || rlp(payload))
. (eips.ethereum.org)keccak256(0x05 || rlp([chain_id, address, nonce])) - Delegation indicator: Client writes
into the EOA’s code; all CALL-like opcodes execute target code in the EOA’s context. EXTCODESIZE sees 23 bytes, while CODESIZE/CODECOPY reflect the delegated code. (eips.ethereum.org)0xef0100 || address - Gas: Intrinsic gas from EIP‑2930 plus
. Base processing per auth is 12,500 gas. Sender pays even if a tuple is invalid. (eips.ethereum.org)PER_EMPTY_ACCOUNT_COST (25000) * authorization_list.length - Revocation and resets: Delegating to the zero address clears the indicator and restores EOA purity (avoids extra cold read on 0x0). (eips.ethereum.org)
- Security assumptions have changed:
is no longer a reliable guard; use proper reentrancy protections. (eips.ethereum.org)tx.origin == msg.sender - Storage collisions: Because delegation doesn’t run initcode, you must manage storage layouts to avoid collisions across upgrades/delegations (e.g., ERC‑7201 namespaced storage). (eips.ethereum.org)
Architecture at a glance
- Onchain: A compact “DelegatedSessionAccount” that implements:
- Initialization with an owner signature (“initWithSig”) to prevent front‑run hijacks. (ethereum.org)
- An EIP‑712 policy for session keys (scopes, caps, expiry, nonce domain).
- A minimal batch executor interface compatible with ERC‑7821 so dapps can prepare batched calls portably. (eips.ethereum.org)
- ERC‑1271 signature validation for dapps that need contract signatures.
- Namespaced storage (ERC‑7201) so future delegations can safely reuse the address. (eips.ethereum.org)
- Offchain:
- Use ERC‑5792
for atomic batching from dapps, and ERC‑4337 bundlers with paymasters for sponsorship. (ethereum.org)wallet_sendCalls - If using vendor SDKs (MetaMask Smart Accounts Kit, Alchemy Account Kit), ensure they’re configured for 7702 targets and audited delegates. (docs.metamask.io)
- Use ERC‑5792
Reference implementation (Solidity, ~250 lines)
Below is a compact, production‑oriented pattern you can drop into your 7702 delegate. It enforces scoped, time‑boxed session keys with per‑tx caps, call filtering, and replay protection. It also exposes an ERC‑7821‑style batch executor for portable batching.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /* Minimal-Trust Session Keys for EIP-7702 Delegation Key properties: - EIP-712-verified session policies (scopes, caps, gas budget, expiry) - Per-session nonces + replay protection - Target + selector allowlists - Per-tx native/erc20 spend caps - ERC-7821-style execute() for batch calls - ERC-1271 contract signatures - ERC-7201 namespaced storage to avoid collisions across delegates Notes: - Designed to be the delegation target of an EOA set via EIP-7702 "set code tx". - Must be initialized by the EOA owner via initWithSig to avoid front-run init. */ interface IERC20 { function transfer(address to, uint256 amount) external returns (bool); } library SigUtils { function toEthSigned(bytes32 digest) internal pure returns (bytes32) { return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", digest)); } } contract DelegatedSessionAccount { // ============ ERC-7201 storage roots ============ // @custom:storage-location erc7201:delegated.account.main struct Main { address owner; // EOA authority uint256 domainSeparatorSalt; mapping(bytes32 => bool) usedOps; // replay protection for op hashes } bytes32 private constant MAIN_LOCATION = 0x5b7a0fffb4e8ad9f6c1b53b6546f2b8c3cbba3c1b6b9ad9d49c4f78b7fbbca00; function _main() private pure returns (Main storage $) { assembly { $.slot := MAIN_LOCATION } } // @custom:storage-location erc7201:delegated.account.sessions struct Sessions { // sessionKey => policy mapping(address => Policy) policyOf; // sessionKey => nonce domain => nonce mapping(address => mapping(uint256 => uint256)) nonces; } bytes32 private constant SESSIONS_LOCATION = 0x2fbe05d1e2b1dc1b60a1e70f5c6a9cc2c30a9c3d9f5f0a9c3b1f0c9a1c0b0a00; function _sessions() private pure returns (Sessions storage $) { assembly { $.slot := SESSIONS_LOCATION } } // ============ Types ============ struct Call { address to; uint256 value; bytes data; } struct Policy { uint64 validAfter; uint64 validUntil; uint128 perTxEthLimit; // wei limit per tx uint128 perTxTokenLimit; // token units (applies when calling allowedToken) address allowedToken; // zero means no token sending via this path bytes32 targetAllowlistRoot; // merkle root of allowed targets (optional) bytes4[] functionSels; // optional allowlist of selectors bool exists; uint32 maxCallsPerBatch; // to bound complexity uint64 gasLimitPerCall; // upper bound on gas forwarded per call uint64 nonceDomain; // domain for per-session nonce } // EIP-712 domain and types bytes32 private constant EIP712_DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,uint256 salt)"); bytes32 private constant POLICY_TYPEHASH = keccak256( "Policy(uint64 validAfter,uint64 validUntil,uint128 perTxEthLimit,uint128 perTxTokenLimit,address allowedToken,bytes32 targetAllowlistRoot,bytes4[] functionSels,bool exists,uint32 maxCallsPerBatch,uint64 gasLimitPerCall,uint64 nonceDomain)" ); bytes32 private constant SESSION_INSTALL_TYPEHASH = keccak256("SessionInstall(address sessionKey,Policy policy,uint256 nonce)"); bytes32 private _DOMAIN_SEPARATOR; // ============ Events ============ event Initialized(address indexed owner); event SessionInstalled(address indexed key, uint64 validUntil, uint64 domain); event SessionRevoked(address indexed key); event Executed(address indexed caller, bytes32 opHash, uint256 calls); // ============ Init ============ // Owner must sign the "init" message to confirm control. Prevents init frontrun. function initWithSig(address owner, bytes calldata sig) external { Main storage m = _main(); require(m.owner == address(0), "already-init"); // Domain separator salt adds replay separation across redelegations. m.domainSeparatorSalt = uint256(keccak256(abi.encode(block.chainid, address(this), block.number))); _DOMAIN_SEPARATOR = _computeDomainSeparator(m.domainSeparatorSalt); bytes32 digest = keccak256( abi.encodePacked( "\x19\x01", _DOMAIN_SEPARATOR, keccak256(abi.encode(keccak256("Init(address owner)"), owner)) ) ); address signer = _recover(digest, sig); require(signer == owner, "bad-init-sig"); m.owner = owner; emit Initialized(owner); } // ============ Session management ============ function installSession(address sessionKey, Policy calldata p, uint256 nonce, bytes calldata ownerSig) external { Main storage m = _main(); require(msg.sender == m.owner, "only-owner"); _verifyPolicySignature(sessionKey, p, nonce, ownerSig); _sessions().policyOf[sessionKey] = p; emit SessionInstalled(sessionKey, p.validUntil, p.nonceDomain); } function revokeSession(address sessionKey) external { Main storage m = _main(); require(msg.sender == m.owner, "only-owner"); delete _sessions().policyOf[sessionKey]; emit SessionRevoked(sessionKey); } // ============ Execution ============ // ERC-7821 style executor with per-call gas and policy enforcement. function execute(Call[] calldata calls, bytes calldata sessionAuth) external payable returns (bytes[] memory results) { (address key, uint256 nonce, bytes32 opHash, Policy memory p) = _validateSessionAuthAndBuildOpHash(calls, sessionAuth); _markOpUsed(opHash); results = new bytes[](calls.length); uint32 callCount; for (uint256 i = 0; i < calls.length; i++) { Call calldata c = calls[i]; _enforceCallPolicy(p, c); // Forward bounded gas per call if specified. bool ok; bytes memory ret; uint256 gasToForward = p.gasLimitPerCall == 0 ? gasleft() : uint256(p.gasLimitPerCall); (ok, ret) = c.to.call{value: c.value, gas: gasToForward}(c.data); require(ok, _bubble(ret)); results[i] = ret; callCount++; require(p.maxCallsPerBatch == 0 || callCount <= p.maxCallsPerBatch, "too-many-calls"); } emit Executed(key, opHash, calls.length); // Increment per-session nonce (prevents cross-batch replay). _sessions().nonces[key][p.nonceDomain] = uint64(nonce + 1); } // EIP-1271 support function isValidSignature(bytes32 hash, bytes memory signature) external view returns (bytes4) { if (_recover(hash, signature) == _main().owner) { return 0x1626ba7e; // ERC1271 magic value } return 0xffffffff; } // ============ Internals ============ function _verifyPolicySignature(address key, Policy calldata p, uint256 nonce, bytes calldata ownerSig) internal view { bytes32 digest = keccak256( abi.encodePacked( "\x19\x01", _DOMAIN_SEPARATOR, keccak256(abi.encode(SESSION_INSTALL_TYPEHASH, key, _policyHash(p), nonce)) ) ); require(_recover(digest, ownerSig) == _main().owner, "bad-owner-sig"); } function _validateSessionAuthAndBuildOpHash(Call[] calldata calls, bytes calldata sessionAuth) internal view returns (address key, uint256 nonce, bytes32 opHash, Policy memory p) { // sessionAuth encodes: key, domain, nonce, expirySig (key, nonce, bytes memory callSig) = abi.decode(sessionAuth, (address, uint256, bytes)); p = _sessions().policyOf[key]; require(p.exists, "no-policy"); require(block.timestamp >= p.validAfter && block.timestamp <= p.validUntil, "expired"); // Call-bound signature: hash(calls, chainId, this, nonceDomain, nonce) bytes32 callsHash = keccak256(_encodeCalls(calls)); bytes32 digest = keccak256( abi.encodePacked( "\x19\x01", _DOMAIN_SEPARATOR, keccak256( abi.encode( keccak256("CallAuth(bytes32 callsHash,uint64 nonceDomain,uint256 nonce)"), callsHash, p.nonceDomain, nonce ) ) ) ); require(_recover(digest, callSig) == key, "bad-session-sig"); // Replay protection: opHash includes the session key & nonce opHash = keccak256(abi.encodePacked(address(this), key, p.nonceDomain, nonce, callsHash)); require(!_main().usedOps[opHash], "replay"); } function _markOpUsed(bytes32 opHash) internal { _main().usedOps[opHash] = true; } function _enforceCallPolicy(Policy memory p, Call calldata c) internal view { // Selector allowlist if (p.functionSels.length > 0 && c.data.length >= 4) { bytes4 sel; assembly { sel := calldataload(c.data.offset) } bool ok; for (uint256 i = 0; i < p.functionSels.length; i++) { if (p.functionSels[i] == sel) { ok = true; break; } } require(ok, "selector-denied"); } // Target allowlist via merkle root (optional) — out-of-scope to verify here for brevity. // Per-transaction ETH limit if (p.perTxEthLimit > 0) require(c.value <= p.perTxEthLimit, "eth-cap"); // Token path: only allow transfers of allowedToken via its transfer() selector if (p.allowedToken != address(0) && c.to == p.allowedToken && c.data.length >= 4) { bytes4 sel; assembly { sel := calldataload(c.data.offset) } require(sel == IERC20.transfer.selector, "token-func"); // Extract amount and enforce cap (address to, uint256 amt) = abi.decode(c.data[4:], (address, uint256)); require(amt <= p.perTxTokenLimit, "token-cap"); require(to != address(0), "bad-to"); } } function _encodeCalls(Call[] calldata calls) internal pure returns (bytes memory out) { out = abi.encode(calls); } function _computeDomainSeparator(uint256 salt) internal view returns (bytes32) { return keccak256( abi.encode( EIP712_DOMAIN_TYPEHASH, keccak256(bytes("DelegatedSessionAccount")), keccak256(bytes("1")), block.chainid, address(this), salt ) ); } function _policyHash(Policy calldata p) internal pure returns (bytes32) { return keccak256( abi.encode( POLICY_TYPEHASH, p.validAfter, p.validUntil, p.perTxEthLimit, p.perTxTokenLimit, p.allowedToken, p.targetAllowlistRoot, keccak256(abi.encodePacked(p.functionSels)), p.exists, p.maxCallsPerBatch, p.gasLimitPerCall, p.nonceDomain ) ); } function _recover(bytes32 digest, bytes memory sig) internal pure returns (address) { if (sig.length == 65) { bytes32 r; bytes32 s; uint8 v; assembly { r := mload(add(sig, 0x20)) s := mload(add(sig, 0x40)) v := byte(0, mload(add(sig, 0x60))) } return ecrecover(digest, v, r, s); } // Support personal_sign fallback return ecrecover(SigUtils.toEthSigned(digest), uint8(sig[64]), bytes32(sig[0:32]), bytes32(sig[32:64])); } function _bubble(bytes memory revertData) internal pure returns (string memory) { if (revertData.length < 4) return "call-reverted"; assembly { revert(add(revertData, 0x20), mload(revertData)) } } }
Key properties mapped to the spec:
- The account is intended as a 7702 delegation target. EOA sets code to
via a set‑code tx. (eips.ethereum.org)0xef0100 || DelegatedSessionAccount
ensures only the real EOA owner can initialize; recommended to avoid front‑running initialization since 7702 does not run initcode. (eips.ethereum.org)initWithSig- ERC‑7201 namespaced storage prevents collisions if you ever change the delegated contract. (eips.ethereum.org)
- Batch executor shape aligns with ERC‑7821 for cross‑vendor batching UX. (eips.ethereum.org)
- No reliance on
or msg.sender assumptions; proper signature checks instead. (eips.ethereum.org)tx.origin
Client integration: three flows
- “Pure 7702” batch from a dapp (no sponsorship):
- Prepare an
tuple signed by the EOA to point to your DelegatedSessionAccount.authorization_list - Send a type‑0x04 transaction executing your desired calls through the delegate.
- Use ERC‑5792
where supported to request a batch from wallets that have whitelisted your delegation. (eips.ethereum.org)wallet_sendCalls
- 7702 + sponsorship (ERC‑4337):
- Keep the same delegation target.
- Route execution through an ERC‑4337 bundler and a paymaster for token‑paid gas or sponsored actions. Favor open bundlers/paymasters for censorship resistance. (ethereum.org)
- SDK‑assisted:
- MetaMask Smart Accounts Kit: includes EIP‑7702 quickstarts and a delegation framework; works with any standard bundler/paymaster. (docs.metamask.io)
- Alchemy Account Kit: Modular Account v2 offers a “7702” mode; ensure you use the audited commit and the 7702‑specific implementation (not a variant with unguarded initializers). (github.com)
Gas and UX tuning
- Each authorization tuple adds 25k intrinsic cost (empty account) plus execution overhead; per‑authorization processing is modeled at 12,500 gas. In practice, one‑time delegation + recurring batches is cheaper and smoother than two‑transaction approve‑then‑use patterns. (eips.ethereum.org)
- Don’t churn delegations: switching targets is a transaction and can increase risk; adopt an executor ABI such as ERC‑7821 so you can keep one audited target. (eips.ethereum.org)
Securing your deployment
- Initialization:
- Require
where the EOA signs the init params; or restrict init to the ERC‑4337 EntryPoint if migrating an existing smart account design. (ethereum.org)initWithSig
- Require
- Storage:
- Use ERC‑7201 namespaced storage and, if migrating between delegates with incompatible layouts, consider a “storage scrubber” delegate before switching. (eips.ethereum.org)
- Session policy hardening:
- Always include nonce domains and per‑session nonces; never accept call execution without a session‑signed digest.
- Limit to explicit selectors and a Merkle allowlist of targets for high‑risk surfaces (routers, NFT marketplaces).
- Enforce per‑tx caps for ETH and tokens; ban approvals/permit writes from session keys unless absolutely needed.
- Reentrancy and
:tx.origin- Replace
checks with standard reentrancy guards; assume callers may be delegated accounts. (ethereum.org)tx.origin
- Replace
- Sponsorship:
- Use paymaster guards and a verifier that simulates and rate‑limits; avoid “open” sponsorship keys. Consider ERC‑7677 paymaster capabilities. (docs.candide.dev)
- Phishing and multichain:
- Highlight the exact delegation target in wallet UIs; caution that
applies to all chains—use chain‑specific authorizations. (ethereum.org)chain_id=0
- Highlight the exact delegation target in wallet UIs; caution that
- Hardware wallets:
- Favor whitelisted, audited delegation targets; do not expose arbitrary delegation prompts on devices. (ethereum.org)
Interop and the vendor landscape (late 2025 snapshot)
- Known audited implementations and proxies exist from teams such as Safe, Alchemy, MetaMask, Ambire, Uniswap/Calibur, EF AA team, and others; wallets should prefer a curated allowlist. (ethereum.org)
- For builders, open‑source delegates and proxies provide secure initialization and upgrade pathways (e.g., Base’s 7702 proxy; Openfort’s 7702 account with passkeys and session policy engine). (github.com)
- Emerging standards:
- ERC‑7821 (minimal batch executor) for consistent batched call interfaces. (eips.ethereum.org)
- ERC‑6900 / ERC‑7579 modular accounts for module interoperability; 7702 targets can align with these to reuse validators and hooks. (eips.ethereum.org)
- ERC‑7710 (smart contract delegation) for standardized session/delegation semantics across vendors. (eips.ethereum.org)
- EIP‑7819 (SETDELEGATE opcode, draft) could eventually enable protocol‑level clones using 7702‑style designators—useful context if you plan factories at scale. (eips.ethereum.org)
Practical example: approval‑less swap with a 24‑hour session
Goal: a user signs once to enable a daily swap budget without granting unlimited approvals.
- Delegation:
- User sends a single 7702 set‑code transaction to delegate to your audited DelegatedSessionAccount. (eips.ethereum.org)
- Session install:
- Wallet constructs a Policy with:
,validAfter=nowvalidUntil=now+86400
(25 USDC),perTxTokenLimit=25e6allowedToken=USDC
(your router’sfunctionSels=[bytes4(0x3593564c)]
),swapExactTokensForETH()maxCallsPerBatch=2nonceDomain=random(64bit)
- Owner signs
and submitsSessionInstall(sessionKey, policy, nonce)
.installSession
- Wallet constructs a Policy with:
- Use:
- Dapp prepares a one‑call or two‑call batch (permit2 + swap) and asks the session key to sign the
.CallAuth(callsHash, domain, nonce) - Contract enforces caps and selectors, then executes atomically via
. If you use ERC‑5792, the wallet can package the batch without bespoke RPCs. (ethereum.org)execute()
- Dapp prepares a one‑call or two‑call batch (permit2 + swap) and asks the session key to sign the
- Sponsorship (optional):
- A paymaster sponsors gas where user has no ETH, with per‑user quotas enforced by a verifier; the system simulates the UserOperation before signing sponsorship. (docs.candide.dev)
Rollout playbook for startups and enterprises
- Week 0–1: Choose a delegate
- Fork the reference contract above; integrate ERC‑7821 executor and ERC‑1271; wire
.initWithSig - Commission a focused audit on init, policy verify, replay protections, and spend caps.
- Fork the reference contract above; integrate ERC‑7821 executor and ERC‑1271; wire
- Week 2–3: Wallet interface and relaying
- Support ERC‑5792 in your frontend; add a 7702 detection path.
- Integrate at least one public 4337 bundler and a paymaster with a verifier for quotas. (ethereum.org)
- Week 4: Canary launch
- Ship on a testnet and a low‑fee L2 with small budgets; enable daily rotations of session keys.
- Instrument metrics: session duration, failure reasons, average calls/batch, gas per successful action.
- Week 5+: Scale and harden
- Add target Merkle allowlists for sensitive actions.
- Implement emergency “scrub and reset delegation” flows if you need to clear storage before upgrades. (eips.ethereum.org)
Common pitfalls and how to avoid them
- Letting dapps request raw 7702 signatures: wallets should whitelist delegates; dapps should use wallet interfaces instead. (eips.ethereum.org)
- Relying on
for anti‑flashloan or reentrancy; use explicit guards and transient storage patterns instead. (ethereum.org)tx.origin - Migrating to a new delegate without namespaced storage or a cleaning step; collisions can brick flows. (eips.ethereum.org)
- Vendor lock‑in: design for any bundler/paymaster plus ERC‑5792; avoid hard‑coding a single relay service. (ethereum.org)
Final take
EIP‑7702 lets you bolt a minimal, auditable policy engine onto an existing EOA—no address change, no multi‑tx onboarding—and run true session keys with hard walls. If you implement the init signature check, namespaced storage, ERC‑7821 executor, and strict EIP‑712 policies, you can ship safer “one‑tap” UX this quarter while staying compatible with ERC‑4337 and the broader smart account stack. For most teams, the winning move is one audited delegate, portable batching, and a sober paymaster strategy—less code to trust, fewer knobs for users, and a smoother path to scale. (eips.ethereum.org)
References used in this article:
- EIP‑7702 spec and security considerations. (eips.ethereum.org)
- Pectra 7702 guidelines (best practices, wallet interfaces, audited implementations, hardware guidance). (ethereum.org)
- ERC‑7201 namespaced storage standard. (eips.ethereum.org)
- ERC‑7821 minimal batch executor (draft). (eips.ethereum.org)
- Paymaster verifier patterns; ERC‑7677 capability. (docs.candide.dev)
- SDKs and implementations (MetaMask, Alchemy, Base proxy, Openfort). (docs.metamask.io)
- Optional context: EIP‑7819 SETDELEGATE opcode (draft). (eips.ethereum.org)
7Block Labs can help you adapt this reference to your stack, audit the policy surfaces, and plan a low‑risk pilot on your target chains.
Like what you're reading? Let's build together.
Get a free 30‑minute consultation with our engineering team.

