LazorKit LogoLazorKit
Concepts

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 closed

Who keeps the key:

  • React SDK (@lazorkit/wallet): createSession generates the key and keeps it for signAndSendWithSession, 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 with keyStorage="memory"). The key is bound to the wallet that created the session: it signs only while that wallet is connected, and KeyWalletMismatchError is thrown otherwise. disconnect() deletes it (also one whose session lands after it) unless you pass disconnect({ keepSessionKeys: true }), and so, since 3.3.1, do the wallet adapter's disconnect() 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, and forgetStoredKeys() deletes every key the SDK keeps. Pass sessionKey (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.

ActionEnforces
SolLimitLifetime SOL cap — decrements until exhausted.
SolRecurringLimitSOL cap per window (e.g. 1 SOL/day). Resets each window.
SolMaxPerTxMax SOL gross outflow per execute.
TokenLimitLifetime token cap per mint.
TokenRecurringLimitToken cap per window per mint.
TokenMaxPerTxMax tokens per execute per mint.
ProgramWhitelistSession can only call this program (repeatable).
ProgramBlacklistSession 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 typeOn expiry
Spending limits (SOL/token)Treated as fully exhausted — any spend rejected.
ProgramWhitelistHard deny — all programs blocked.
ProgramBlacklistSilently 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 with ActionUnlistedSolOutflow (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 with ActionUnlistedTokenOutflow (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:

ActionBytes
SolLimit, SolMaxPerTx19
SolRecurringLimit43
TokenLimit, TokenMaxPerTx51
TokenRecurringLimit75
ProgramWhitelist, ProgramBlacklist43

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 as DeferredExpiredError. 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 DeferredExec keeps its rent until the TX1 payer closes it with ReclaimDeferred; only that payer can, and only after the window.

Trade-offs

MetricImmediate ExecuteDeferred (2 txs)
Inner instruction capacity¹~345 bytes~825 bytes in TX2 (~2.4×)
Total CU²~9,495~15,667
Tx fee0.000005 SOL0.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

ItemCost
Session account rent (one-time)0.001448 SOL
CreateSession tx fee0.000005 SOL
Total setup0.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

ErrorCause
0xbd0 ActionSolLimitExceededLifetime SOL cap exhausted — revoke + recreate with a higher solLimit.
0xbd1 ActionSolRecurringLimitExceededWindow cap hit. Wait for reset or raise solRecurringLimit.
0xbc1 SessionExpiredSession past its expiry slot — make a new one.
0xbc0 InvalidSessionDurationExpiry not in the future, or more than 6,480,000 slots ahead.
0xbcd ActionProgramNotWhitelistedThe 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 SessionNotExpiredCloseExpiredSession on a live session.
0xbdd ActionUnlistedSolOutflowThe 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 ActionUnlistedTokenOutflowA token would leave whose mint no Token* action names. From the same release. UnlistedTokenOutflowError.

Full map in Errors.