LazorKit LogoLazorKit
SDK (web3.js v1)

Session Keys

Create, use, and revoke session keys with the low-level web3.js v1 SDK.

Session keys let an app or agent execute on a wallet's behalf without passkey re-authentication. This page covers the low-level @lazorkit/sdk-legacy 1.x API (npm install @lazorkit/sdk-legacy@next) — see Session Keys concept for the protocol-level explanation and React SDK / React Native SDK for higher-level wrappers.


Create a session

When the admin is an Ed25519 keypair, sign immediately with a single call:

import { Keypair, SystemProgram } from '@solana/web3.js';
import { Actions, ed25519 } from '@lazorkit/sdk-legacy';

const sessionKp = Keypair.generate();
const slot = await connection.getSlot();

const { instructions, sessionPda } = await client.createSession({
  payer: payer.publicKey,
  walletPda,
  adminSigner: ed25519(ownerKp.publicKey),
  sessionKey: sessionKp.publicKey,
  expiresAt: BigInt(slot) + 216_000n,           // ~1 day
  actions: [
    Actions.solMaxPerTx(1_000_000_000n),        // 1 SOL per tx
    Actions.solLimit(10_000_000_000n),          // 10 SOL lifetime
    Actions.programWhitelist(SystemProgram.programId),
  ],
});

expiresAt is an absolute slot, not a Unix timestamp: at most 6,480,000 slots ahead (about 30 days at 400 ms), or the program refuses it with InvalidSessionDuration (3008).

For Secp256r1 (passkey) admins, use the two-phase prepare / finalize flow:

import { Actions } from '@lazorkit/sdk-legacy';

// 1. Compute the challenge
const prepared = await client.prepareCreateSession({
  payer: payer.publicKey,
  walletPda,
  secp256r1: { credentialIdHash, publicKeyBytes, authorityPda },
  sessionKey: sessionKp.publicKey,
  expiresAt: BigInt(slot) + 432_000n,           // ~2 days
  actions: [
    // USDC only: no Sol* action, so from the program release that adds error 3037 it
    // cannot spend SOL, rent for a new account included.
    Actions.tokenLimit({ mint: USDC_MINT, remaining: 1_000_000_000n }),
    Actions.tokenMaxPerTx({ mint: USDC_MINT, max: 100_000_000n }),
  ],
});

// 2. Authenticator signs the challenge
const webauthnResponse = await getWebAuthnResponse(
  prepared.challenge,
  'your-app.com',
  credentialId,
);

// 3. Build the transaction
const { instructions, sessionPda } = client.finalizeCreateSession(prepared, webauthnResponse);

getWebAuthnResponse is the helper from Passkey signing.

A session with no actions has no on-chain limits: it can spend the whole vault through any program until it expires. Since 1.0.0 the SDK refuses one unless you say so:

import { ed25519 } from '@lazorkit/sdk-legacy';

const { instructions, sessionPda } = await client.createSession({
  payer: payer.publicKey,
  walletPda,
  adminSigner: ed25519(ownerKp.publicKey),
  sessionKey: sessionKp.publicKey,
  expiresAt: BigInt(slot) + 9_000n,             // ~1 hour at 400 ms a slot
  unrestricted: true,                           // required when there are no actions
});

Actions are immutable

A session's action list can't be modified after creation. To change limits, revoke and recreate. The program does not create a second session for a key while the first exists: revoke first, or use a new key.

Name every asset the session may spend. From the v2 program release that adds errors 3037 and 3038, a session's actions (and a Delegate's policy) name what may leave the vault, and nothing else may: with no Sol* action the vault's SOL may not fall (3037), rent the vault pays for a new account included, and a mint with no Token* action may not leave the vault's token accounts (3038). Both are net over one Execute, inflows always pass, and wSOL is a mint, not SOL. A program whitelist names programs, not assets. For a swap, name the mint it sells, and create its output token account in a top-level instruction the fee payer funds, before the Execute, or give the session a solLimit that covers the rent. That release is not deployed yet: until it is, an asset the actions do not name is not bounded at all. With a passkey admin, keep the actions within about 244 bytes so they fit beside its response. See Session Keys › What a policy bounds.


Execute via session key

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,                       // 0.5 SOL — within the 1 SOL per-tx cap
});

No passkey prompt — the session keypair signs at transaction level.

For arbitrary instructions, use client.execute:

const { instructions } = await client.execute({
  payer: payer.publicKey,
  walletPda,
  signer: session(sessionPda, sessionKp.publicKey),
  instructions: [jupiterSwapIx],
});

Deferred execution

For payloads too large for a single Secp256r1 Execute, use the 2-transaction deferred flow; in practice that includes Jupiter swaps. A v0 transaction is capped at 1232 bytes, and an Execute with no inner instruction already takes about 887 of them (no lookup table, no ComputeBudget instruction, the LazorKit portal's clientDataJSON in an iframe). That leaves about 345 bytes for inner instructions: about 305 with a compute-unit limit, 109 fewer when Chrome pads clientDataJSON. Your own clientDataJSON moves the figure by its length. ExecuteDeferred (TX2) has no precompile or clientDataJSON and leaves about 825 bytes. Measured 2026-09-29 with @lazorkit/sdk-legacy 1.2.0.

Only passkey Owners and Admins can authorize

Authorize (TX1) is restricted to Secp256r1 Owner or Admin authorities without a policy. Ed25519 keys and Delegates can't call it.

TX1 — Authorize (passkey signs)

import { Transaction, sendAndConfirmTransaction } from '@solana/web3.js';

const prepared = await client.prepareAuthorize({
  payer: payer.publicKey,
  walletPda,
  secp256r1: { credentialIdHash, publicKeyBytes, authorityPda },
  instructions: innerInstructions,              // payload to pre-authorize
  expiryOffset: 1_500,                          // slots TX2 stays valid; SDK default 300, 10–9000
  // executor: relayer.publicKey,               // who sends TX2, when not the payer (see below)
});

const webauthnResponse = await getWebAuthnResponse(prepared.challenge, 'your-app.com', credentialId);
const { instructions, deferredPayload } = client.finalizeAuthorize(prepared, webauthnResponse);

await sendAndConfirmTransaction(connection, new Transaction().add(...instructions), [payer]);

TX2 — ExecuteDeferred (no passkey)

import { Transaction, sendAndConfirmTransaction } from '@solana/web3.js';

const { instructions: tx2 } = await client.executeDeferredFromPayload({
  payer: payer.publicKey,
  deferredPayload,                              // from finalizeAuthorize
});
await sendAndConfirmTransaction(connection, new Transaction().add(...tx2), [payer]);

Hashes must match

The program verifies TX2's instructions + accounts hash matches what TX1 recorded. Any mismatch is rejected with DeferredHashMismatch (0xbc7). That includes TX2 sent by another key than the executor TX1 named, when an inner instruction pays TX2's payer or the TX1 payer back: executeDeferredFromPayload refuses such a sender rather than build a TX2 that would fail.

The window. TX2 is accepted for expiryOffset slots after TX1's slot (10–9000). The SDK's default is 300; the React and React Native SDKs default to 1500. A slot's length varies by cluster and load. Past the window TX2 fails with DeferredAuthorizationExpired (3014), and until then the authorization stays executable by anyone holding the payload: nothing can cancel it.

A 3014 can also come from an inner program (Anchor's AccountNotAssociatedTokenAccount). Before telling a user their approval expired, check that the first Program <id> failed log line names the LazorKit program — see Errors.

Cross-machine deferred flows

TX1 and TX2 don't need to run on the same machine. You can sign TX1 on the user's device, serialize the deferred payload, POST it to a relayer, and submit TX2 from there:

import {
  serializeDeferredPayload,
  deserializeDeferredPayload,
} from '@lazorkit/sdk-legacy';

// User device (after finalizing TX1):
const { deferredPayload } = client.finalizeAuthorize(prepared, webauthnResponse);
const wire = serializeDeferredPayload(deferredPayload);   // JSON-safe string
await fetch('/relayer', { method: 'POST', body: wire });

// Relayer:
const payload = deserializeDeferredPayload(await req.text());
const { instructions } = await client.executeDeferredFromPayload({
  payer: relayerKeypair.publicKey,
  deferredPayload: payload,
});

All fields serialize as base58 (pubkeys) or base64 (raw bytes) — safe over HTTP, WebSocket, or any store-and-forward channel. The serialized payload carries version: 2 and records the executor and the refund destination; a payload from the other protocol version is refused.

Reclaim an expired authorization

If TX2 is never submitted, recover the DeferredExec account rent after expiry:

const { instructions } = client.reclaimDeferred({
  payer: payer.publicKey,           // the payer that funded TX1
  deferredExecPda,
  refundDestination: payer.publicKey,
});

Only the original payer can reclaim, only to itself (UnauthorizedReclaim, 3017), and only after the window has passed (DeferredAuthorizationNotExpired, 3018).


Revoke a session

import { ed25519 } from '@lazorkit/sdk-legacy';

const { instructions } = await client.revokeSession({
  payer: payer.publicKey,
  walletPda,
  adminSigner: ed25519(ownerKp.publicKey),
  sessionPda,
  refundDestination: payer.publicKey,
});

Closes the session PDA and refunds rent. Works on active or already-expired sessions. Owner or Admin only.

After a session expires, anyone may close it with CloseExpiredSession (instruction 18) and keep the rent; before that the program refuses with SessionNotExpired (3036). 1.4.0 has no public builder for it; @lazorkit/sdk exports createCloseExpiredSessionIx.


Delegate authorities

A key that should spend within limits for good, with no expiry, is a Delegate authority rather than a session. It carries a policy built from the same actions:

import { Actions, ROLE_SPENDER, ed25519, serializeActions } from '@lazorkit/sdk-legacy';

const { instructions, newAuthorityPda } = await client.addAuthority({
  payer: payer.publicKey,
  walletPda,
  adminSigner: ed25519(ownerKp.publicKey),
  newAuthority: { type: 'ed25519', publicKey: sessionKp.publicKey },
  role: ROLE_SPENDER,
  // SOL only: from the program release that adds error 3038, this Delegate cannot lower
  // the vault's balance of any token. Add a Token* action for each mint it may spend.
  policy: serializeActions([Actions.solLimit(10_000_000_000n), Actions.solMaxPerTx(1_000_000_000n)]),
});

A Delegate without a policy is refused (3033), and a policy on an Owner or Admin too (3035). Its policy names what may leave the vault, as a session's actions do (above). See Ranks & policies.


Action types reference

TypeFieldsBuilder
SolLimitremainingActions.solLimit(remaining, expiresAt?)
SolRecurringLimitlimit, windowActions.solRecurringLimit({ limit, window, expiresAt? })
SolMaxPerTxmaxActions.solMaxPerTx(max, expiresAt?)
TokenLimitmint, remainingActions.tokenLimit({ mint, remaining, expiresAt? })
TokenRecurringLimitmint, limit, windowActions.tokenRecurringLimit({ mint, limit, window, … })
TokenMaxPerTxmint, maxActions.tokenMaxPerTx({ mint, max, expiresAt? })
ProgramWhitelistprogramIdActions.programWhitelist(programId, expiresAt?)
ProgramBlacklistprogramIdActions.programBlacklist(programId, expiresAt?)

All SOL/token amounts are bigint (lamports for SOL; base units for tokens). Max 16 actions per session, 2048-byte total buffer; with a passkey admin, about 244 bytes fit beside its response (How many actions fit).

See Session Keys concept for semantics and edge cases (expired actions, gross outflow tracking, etc.).


Common session errors

CodeNameFix
0xbd0ActionSolLimitExceededLifetime cap hit. Revoke + recreate with higher solLimit.
0xbd1ActionSolRecurringLimitExceededWindow cap hit. Wait for reset or raise the limit.
0xbc1SessionExpiredPast expiresAtSlot. Close and create a new session.
0xbcdActionProgramNotWhitelistedCPI target isn't in the whitelist. Add it or drop the filter.
0xbc6DeferredAuthorizationExpiredTX2 after the window (or an inner program's 3014: check the logs).
0xbc7DeferredHashMismatchTX2 instructions, accounts or sender differ from TX1's authorization.
0xbd9DelegateRequiresPolicyROLE_SPENDER added without a policy.
0xbdcSessionNotExpiredCloseExpiredSession on a live session.

Full map in Errors › Program error codes.