Session Keys
Scoped, time-bound delegation keys — action types, spending limits, what a policy bounds, deferred execution, and revocation.
A session key is an ephemeral Ed25519 keypair with an expiry slot and a list of action constraints. It lets an app or agent execute on behalf of a wallet without a passkey prompt per transaction.
When to use session keys
Session keys are for delegation — giving an app or agent scoped permission to act on the user's behalf. You don't need one for direct user execution: a passkey owner can always sign a transaction themselves.
Reach for a session key when:
- An agent / bot executes on a schedule without the user present
- An app wants to batch or automate actions without prompting every time
- You need to constrain what an executor can do (which programs, what value, until when)
A key that should keep its limits for good, with no expiry, is a Delegate authority instead; see Ranks & policies.
Mental model
1. Owner/Admin creates a Session PDA with a fresh Ed25519 public key + action list
2. The session's secret key is kept by whoever will sign with it (see below)
3. The holder signs Execute with the session key — no passkey prompt
4. The program enforces the action list on every Execute the key signs
5. At the expiry slot the session stops signing; its account can then be closedWho keeps the key:
- React SDK (
@lazorkit/wallet):createSessiongenerates the key and keeps it forsignAndSendWithSession, as a non-extractable WebCrypto key in IndexedDB (a sealed seed where the browser has no WebCrypto Ed25519, the page's memory without IndexedDB or withkeyStorage="memory"). The key is bound to the wallet that created the session: it signs only while that wallet is connected, andKeyWalletMismatchErroris thrown otherwise.disconnect()deletes it (also one whose session lands after it) unless you passdisconnect({ keepSessionKeys: true }), and so, since 3.3.1, do the wallet adapter'sdisconnect()and the Wallet Standard's, which since 3.4.0 disconnect the store too. A send still running when the wallet is disconnected, whichever way, neither signs nor sends after it (3.4.0). A key whose session has expired is deleted when it is next read, andforgetStoredKeys()deletes every key the SDK keeps. PasssessionKey(a public key) to register a key your backend holds instead; it is never stored. See What the SDK stores. - React Native SDK and the contract SDKs: you generate the keypair and keep the secret
key yourself, in secure storage (
expo-secure-store, a KMS, a backend vault). Keep the wallet it belongs to with it, use it only while that wallet is connected, and delete it at disconnect and once its session is revoked or expired.
Changed in 3.3.0
@lazorkit/wallet 3.2.1 and earlier kept the session key in localStorage as plain
text, signed with it whichever wallet was connected, and did not delete it at
disconnect.
Sessions are the cheapest execution path: no Secp256r1 precompile runs. Measured in
April 2026 on protocol v1, a session Execute took about 4,105 CU against about 9,495 CU
for a passkey Execute; v2 adds a hash input and runs the policy engine, and has not been
re-measured.
Creating a session
Every SDK refuses to create a session with no actions unless you pass
unrestricted: true: such a session can spend the whole vault through any program until
it expires. (Since @lazorkit/wallet 3.0.0, @lazorkit/wallet-mobile-adapter 2.0.0 and
@lazorkit/sdk-legacy 1.0.0.)
Name every asset the session may spend: SOL with a Sol* action, and each token by its
mint. From the v2 program release that adds errors 3037 and 3038, an asset the actions do
not name cannot leave the wallet; see What a policy bounds.
From the React Native hook
The RN SDK takes the raw SessionAction[] catalogue:
import { Actions, useWallet } from '@lazorkit/wallet-mobile-adapter';
import { Keypair } from '@solana/web3.js';
const sessionKp = Keypair.generate();
const currentSlot = BigInt(await connection.getSlot());
const { sessionPda } = await createSession(
{
sessionKey: sessionKp.publicKey,
expiresAtSlot: currentSlot + 216_000n, // ~24h at 400 ms a slot
actions: [
Actions.solRecurringLimit({ limit: 1_000_000_000n, window: 216_000n }),
Actions.solMaxPerTx(500_000_000n),
Actions.tokenMaxPerTx({ mint: USDC_MINT, max: 50_000_000n }), // 50 USDC a transaction
Actions.programWhitelist(JUPITER_PROGRAM_ID),
],
},
{ redirectUrl: 'myapp://cb' },
);Persist sessionKp.secretKey in secure storage along with sessionPda. The adapter does
not check the actions' size before the portal opens: keep them within
244 bytes.
From the React hook
The React SDK takes a higher-level SpendingLimits wrapper: SOL limits, and since 3.4.0
tokens, one entry per mint the session may spend.
const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
const { sessionPda, sessionPublicKey } = await createSession({
expiresInSlots: 216_000n, // ~24h; default 50,000 slots
spendingLimits: {
solPerTxMax: 500_000_000n, // 0.5 SOL per tx, rent the vault pays included
solRecurring: {
limit: 1_000_000_000n, // 1 SOL per window
windowSlots: 216_000n, // 24h window
},
// 5 USDC per tx, 100 USDC in all. Amounts in the mint's base units.
tokens: [{ mint: USDC, perTxMax: 5_000_000n, lifetimeCap: 100_000_000n }],
},
});createSession checks the limits before anything is read or the passkey is asked: a
tokens entry with no limit, a mint named twice, an amount outside a u64, a window of 0
slots, more than 16 actions or more than 244 bytes of actions
throws. Nothing is added that you did not ask for. Program filters and per-action
expiries are not in SpendingLimits: for those, build the session with LazorKitClient
from @lazorkit/sdk-legacy and the Actions catalogue (below), with an Ed25519 admin key
or with prepareCreateSession and a passkey assertion you collect yourself.
From the low-level SDK
import { Connection, Keypair, SystemProgram } from '@solana/web3.js';
import { Actions, LazorKitClient, PROGRAM_ID_DEVNET, ed25519 } from '@lazorkit/sdk-legacy';
const connection = new Connection('https://api.devnet.solana.com', 'confirmed');
const client = new LazorKitClient(connection, PROGRAM_ID_DEVNET);
const sessionKp = Keypair.generate();
const currentSlot = BigInt(await connection.getSlot());
// Ed25519 admin signs immediately:
const { instructions, sessionPda } = await client.createSession({
payer: payer.publicKey,
walletPda,
adminSigner: ed25519(ownerKp.publicKey),
sessionKey: sessionKp.publicKey,
expiresAt: currentSlot + 216_000n,
actions: [Actions.programWhitelist(SystemProgram.programId), Actions.solMaxPerTx(1_000_000_000n)],
});For Secp256r1 (passkey) admins, use prepareCreateSession / finalizeCreateSession —
see web3.js v1 › Session Keys.
Actions are immutable
Once a session is created, its action list can't be modified. To change limits, revoke
the session and create a new one. For a key you hold (sessionKey on web, every key on
mobile), revoke the old session before you register the key again, or use a new key:
the program does not create a second session for a key while the first exists, and
@lazorkit/wallet resolves with that existing session, as it was made, whatever limits
you pass.
Actions reference
Actions constrain what a session key — or a Delegate authority, whose policy is the same
kind of list — can do. The program checks them around every inner instruction during
Execute. Max 16 actions, up to 2048 bytes; a policy a passkey approves must also fit
in 244 bytes.
| Action | Enforces |
|---|---|
SolLimit | Lifetime SOL cap — decrements until exhausted. |
SolRecurringLimit | SOL cap per window (e.g. 1 SOL/day). Resets each window. |
SolMaxPerTx | Max SOL gross outflow per execute. |
TokenLimit | Lifetime token cap per mint. |
TokenRecurringLimit | Token cap per window per mint. |
TokenMaxPerTx | Max tokens per execute per mint. |
ProgramWhitelist | Session can only call this program (repeatable). |
ProgramBlacklist | Session cannot call this program (repeatable). |
Gross outflow tracking
SolMaxPerTx uses per-CPI lamport snapshotting, not net flow. A DeFi round-trip
(swap out + swap back) can't bypass the cap by ending at the same balance.
Name every asset the key may spend
On the v2 program deployed today, a list bounds only what it names: a whitelist with no
SolLimit leaves SOL uncapped, and a SOL cap leaves tokens uncapped. From the v2
program release that adds errors 3037 and 3038, an asset the list does not name cannot
leave at all (below). Either way, give every asset the key
should spend a limit, and nothing else. Whatever the actions, the program also refuses
an inner instruction that reassigns the vault or changes a vault token account's owner,
delegate or close authority (3030–3032).
Builder helpers
import { Actions } from '@lazorkit/wallet-mobile-adapter';
// or '@lazorkit/wallet' / '@lazorkit/sdk-legacy'
import type { PublicKey } from '@solana/web3.js';
declare const lamports: bigint, limit: bigint, window: bigint, mint: PublicKey, programId: PublicKey;
Actions.solLimit(lamports);
Actions.solRecurringLimit({ limit, window });
Actions.solMaxPerTx(lamports);
Actions.tokenLimit({ mint, remaining: limit });
Actions.tokenRecurringLimit({ mint, limit, window });
Actions.tokenMaxPerTx({ mint, max: limit });
Actions.programWhitelist(programId);
Actions.programBlacklist(programId);Every builder also takes an optional expiresAt slot for that action alone.
Expired action behaviour
Actions have their own expiresAt independent of session expiry.
| Action type | On expiry |
|---|---|
| Spending limits (SOL/token) | Treated as fully exhausted — any spend rejected. |
ProgramWhitelist | Hard deny — all programs blocked. |
ProgramBlacklist | Silently dropped — ban lifted. |
What a policy bounds
A session's actions, and a Delegate's policy, name what may leave the wallet. From the v2 program release that adds errors 3037 and 3038, nothing they do not name may.
Not deployed yet
That program release is not deployed on devnet or mainnet yet (Networks & versions). Until it is, an asset the actions do not name is not bounded at all: a session with token limits only can spend all the wallet's SOL, and one with SOL limits only any token. Write the limits you want now. That release also tightens how named token limits are measured, so until it is deployed, treat the token limits of a session or Delegate as weaker than this page describes.
From that release, for an Execute signed by a session with actions or by a Delegate:
- SOL leaves the vault only when a
Sol*action (SolLimit,SolRecurringLimit,SolMaxPerTx) names it. Without one, a transaction that lowers the vault's SOL balance fails withActionUnlistedSolOutflow(3037), rent the vault pays for a new account included (a recipient's token account, say). The network fee is the fee payer's, not the vault's. - A token leaves only when a
Token*action names its mint. Any other mint fails withActionUnlistedTokenOutflow(3038). wSOL is a mint of its own: a SOL action does not name it, and a wSOL action does not name SOL. - Net per asset, over one transaction, and what comes in always passes. A swap needs an action for the mint it sells, and a SOL action when the vault pays the rent of its output token account (or create that account first, in an instruction the fee payer funds). A program whitelist names programs, not assets.
- The vault's token accounts that a transaction passes writable may change only their
balance: owner, delegate, close authority and state stay as they were
(
SessionTokenAuthorityChanged, 3032). - No opt-out. No action lifts the rule. A key that must move any asset is an unbounded
one: an Owner, an Admin, or a session created with
unrestricted: true, which has no policy and none of these bounds.
Value the vault controls in other ways than its own SOL and the token accounts it owns
(stake or nonce accounts it is the authority of, positions in other programs, authorities
it holds over mints or programs) is bounded only by the program whitelist. Value such an
account releases into the vault during a transaction can leave again in that same
transaction, whatever the actions name. Keep the whitelist narrow for a key on such a
wallet. The full rule set is in
docs/Architecture.md › What a policy bounds.
The wallet SDKs report the two refusals as UnlistedSolOutflowError and
UnlistedTokenOutflowError (@lazorkit/wallet 3.4.0, @lazorkit/wallet-mobile-adapter
2.4.0); see Errors.
Sessions and Delegates made before that release
A session or Delegate whose actions name SOL only can move no token once the release is
deployed. That includes every session @lazorkit/wallet's SpendingLimits made before
3.4.0, which had no tokens. Actions cannot be changed: revoke the session (remove the
Delegate) and register one that names each mint it spends. A session key the React SDK
generates is new each time. For a key you hold, revoke its session first or register a new
key: the program does not create a second session for a key while the first exists.
How many actions fit
A policy holds at most 16 actions, and at most one of each kind per mint. When a passkey
approves the transaction that registers it (CreateSession, or AddAuthority for a
Delegate), the actions share that 1232-byte transaction with the passkey's response, whose
clientDataJSON the browser writes (up to about 300 bytes). Keep them within 244
bytes:
| Action | Bytes |
|---|---|
SolLimit, SolMaxPerTx | 19 |
SolRecurringLimit | 43 |
TokenLimit, TokenMaxPerTx | 51 |
TokenRecurringLimit | 75 |
ProgramWhitelist, ProgramBlacklist | 43 |
So SolMaxPerTx with TokenMaxPerTx and TokenLimit for 2 mints (223 bytes), or
SolMaxPerTx with TokenMaxPerTx for 4 mints (223). @lazorkit/wallet's createSession
refuses a SpendingLimits over 244 bytes before the passkey is asked (3.4.0). Its
addAuthority, the React Native SDK and the contract SDKs do not check: actions that do not
fit fail after the user approved.
Common session patterns
Unrestricted session (no limits)
await createSession(
{
sessionKey: sessionKp.publicKey,
expiresAtSlot: currentSlot + 9_000n, // ~1 hour at 400 ms a slot
unrestricted: true, // required: no actions means no limits
},
{ redirectUrl: 'myapp://cb' },
);An unrestricted session can do anything the wallet can until it expires. Use one only for a key you hold as carefully as the wallet itself.
Jupiter-only swapper
await createSession(
{
sessionKey: sessionKp.publicKey,
expiresAtSlot: currentSlot + 216_000n,
actions: [
Actions.programWhitelist(JUPITER_V6_PROGRAM_ID),
Actions.programWhitelist(TOKEN_PROGRAM_ID), // SPL transfers for ATAs
Actions.solMaxPerTx(1_000_000_000n), // SOL it sells, and rent for output accounts
Actions.tokenMaxPerTx({ mint: USDC_MINT, max: 100_000_000n }), // the token it sells
],
},
{ redirectUrl: 'myapp://cb' },
);Name each mint the swapper sells; from the program release that adds error 3038, a swap that sells any other token fails.
Usage-capped session (1 SOL per day)
await createSession(
{
sessionKey: sessionKp.publicKey,
expiresAtSlot: currentSlot + 1_512_000n, // ~7 days at 400 ms a slot
actions: [
// SOL only: from the program release that adds error 3038, it moves no token.
Actions.solRecurringLimit({ limit: 1_000_000_000n, window: 216_000n }),
],
},
{ redirectUrl: 'myapp://cb' },
);Stablecoin-only spender
await createSession(
{
sessionKey: sessionKp.publicKey,
expiresAtSlot: currentSlot + 216_000n,
actions: [
Actions.tokenLimit({ mint: USDC_MINT, remaining: 100_000_000n }), // 100 USDC
Actions.tokenMaxPerTx({ mint: USDC_MINT, max: 10_000_000n }), // 10 USDC per tx
Actions.programBlacklist(SUSPICIOUS_PROGRAM_ID),
// No SOL action: from the program release that adds error 3037, no SOL can leave,
// rent included, and no other token (3038). Until then neither is capped.
],
},
{ redirectUrl: 'myapp://cb' },
);The program caps sessions at 6,480,000 slots (about 30 days at 400 ms) from creation
(InvalidSessionDuration, 3008).
Executing with a session
React Native
const sig = await signAndSendWithSession({
sessionKeypair: sessionKp,
sessionPda,
instructions: [ix],
});No passkey prompt. The Ed25519 keypair signs at transaction level.
React
const sig = await signAndSendWithSession({ instructions: [ix] });The SDK signs with the session key it kept during createSession, only while the wallet
that created the session is connected. There is one slot: a new createSession replaces
the kept key, and revokeSession() and disconnect() delete it (unless
disconnect({ keepSessionKeys: true })). A send still running when the wallet is
disconnected neither signs nor sends after it, even if the wallet is connected again by
then (3.4.0): it rejects with KeyWalletMismatchError. The session itself stays on chain,
within its limits, until it expires or is revoked: on a shared device, revoke sessions
when the user signs out.
Low-level
import { session } from '@lazorkit/sdk-legacy';
const { instructions } = await client.transferSol({
payer: payer.publicKey,
walletPda,
signer: session(sessionPda, sessionKp.publicKey),
recipient,
lamports: 500_000_000n,
});Deferred execution
For payloads too big to fit in a single Secp256r1 Execute transaction, LazorKit provides a
2-transaction deferred flow. A passkey Execute leaves only about 345 bytes for inner
instructions (see Trade-offs), so in practice a Jupiter swap needs it: a
single-hop SOL→USDC route, with Jupiter's lookup tables and compute-budget instructions, came
to 1245 bytes through the portal in an iframe, and to 1207 through a browser tab, which
Chrome's clientDataJSON padding pushes over the limit.
From the SDKs
The React and React Native SDKs wrap both transactions into a single method that takes one passkey prompt:
// React Native
const sig = await authorizeAndExecute(
{ instructions: jupiterSwapIxs },
{ redirectUrl: 'myapp://cb' },
);// React
const sig = await authorizeAndExecute({ instructions: jupiterSwapIxs });The window
TX2 is accepted for expiryOffset slots after the slot TX1 landed in: 10 to 9000.
The React and React Native SDKs default to 1500 (since 3.2.0 / 2.2.0);
@lazorkit/sdk-legacy's prepareAuthorize defaults to 300. A slot's length depends on
the cluster and its load, so 9000 slots is tens of minutes, not hours.
- Past the window, TX2 fails with
DeferredAuthorizationExpired(3014); the UI SDKs report it asDeferredExpiredError. The approval is spent. - Until the window ends, the authorization stays executable, and nothing can cancel it: anyone holding the payload can send TX2.
- An expired
DeferredExeckeeps its rent until the TX1 payer closes it withReclaimDeferred; only that payer can, and only after the window.
Trade-offs
| Metric | Immediate Execute | Deferred (2 txs) |
|---|---|---|
| Inner instruction capacity¹ | ~345 bytes | ~825 bytes in TX2 (~2.4×) |
| Total CU² | ~9,495 | ~15,667 |
| Tx fee | 0.000005 SOL | 0.00001 SOL |
| Temp rent (refunded to payer) | — | 0.002116 SOL |
¹ Room left under Solana's 1232-byte transaction limit once the transaction's own overhead
is in: v0, no address lookup table, no ComputeBudget instruction, the portal's
clientDataJSON in an iframe (about 40 bytes shorter from a browser tab or popup, which
leaves about 380). An empty passkey Execute is about 887 bytes; a compute-unit limit costs
about 40 more and Chrome's padded clientDataJSON 109 more, so under 200 bytes are left with
both. TX1 carries only hashes; an empty ExecuteDeferred (TX2) is about 406 bytes. TX2 is
still one transaction, though: with Jupiter's compute-budget instructions, a three-hop route
came to 1846 bytes there without Jupiter's lookup tables and 1111 with them, and a five-leg
split route did not fit even with them (1328). Pass the lookup tables, and cap the route with
Jupiter's maxAccounts when you quote. Measured 2026-09-29 with @lazorkit/sdk-legacy 1.2.0.
² Measured in April 2026 on protocol v1; not re-measured on v2.
Only a passkey Owner or Admin can call Authorize (TX1). TX2 can be submitted by any
signer — but if an inner instruction pays the TX1 payer or TX2's payer back, TX2 must come
from the executor named at TX1. See Passkey signing.
Revocation
Before expiry, a session can be revoked by an Owner or Admin:
// React Native
await revokeSession(
{ sessionPda, refundDestination: smartWalletPubkey! },
{ redirectUrl: 'myapp://cb' },
);// React: the session it created, or a specific one (e.g. created with your own sessionKey)
await revokeSession();
await revokeSession({ sessionPda: 'SESSION_PDA_BASE58' });RevokeSession closes the session PDA and refunds the rent, before or after expiry. Use
refundDestination: smartWalletPubkey (the vault, on mobile) to keep the rent in the
wallet.
After expiry, anyone may close the session with CloseExpiredSession (instruction
18, protocol v2) and keep the rent: the account authorises nothing any more. A session is
closable only once the slot is strictly past its expiry; before that the program refuses
with SessionNotExpired (3036). @lazorkit/sdk exports createCloseExpiredSessionIx;
@lazorkit/sdk-legacy 1.4.0 has no public builder for it.
Costs
| Item | Cost |
|---|---|
| Session account rent (one-time) | 0.001448 SOL |
CreateSession tx fee | 0.000005 SOL |
| Total setup | 0.001453 SOL |
Execute via session (per tx) | 0.000005 SOL |
The rent comes back through RevokeSession — or, once the session has expired, to
whoever calls CloseExpiredSession first.
Troubleshooting session errors
| Error | Cause |
|---|---|
0xbd0 ActionSolLimitExceeded | Lifetime SOL cap exhausted — revoke + recreate with a higher solLimit. |
0xbd1 ActionSolRecurringLimitExceeded | Window cap hit. Wait for reset or raise solRecurringLimit. |
0xbc1 SessionExpired | Session past its expiry slot — make a new one. |
0xbc0 InvalidSessionDuration | Expiry not in the future, or more than 6,480,000 slots ahead. |
0xbcd ActionProgramNotWhitelisted | The call targets a program outside the whitelist — add it or drop the filter. |
0xbd6–0xbd8 (3030–3032) | An inner instruction tried to reassign the vault or a vault token account's authority. |
0xbdc SessionNotExpired | CloseExpiredSession on a live session. |
0xbdd ActionUnlistedSolOutflow | The vault's SOL would fall (rent included) and no Sol* action names SOL. From the program release that adds it. UnlistedSolOutflowError in the wallet SDKs. |
0xbde ActionUnlistedTokenOutflow | A token would leave whose mint no Token* action names. From the same release. UnlistedTokenOutflowError. |
Full map in Errors.