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 API — 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. Max duration ~30 days.

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: [
    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);

Omit actions for a session with no on-chain limits — still bounded by expiry:

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
});

Actions are immutable

A session's action list can't be modified after creation. To change limits, revoke and recreate.


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 exceeding the ~574 bytes available in a single Secp256r1 Execute (e.g. Jupiter swaps with routes + ATAs), use the 2-transaction deferred flow.

Only Secp256r1 Owner/Admin can authorize

Authorize (TX1) is restricted to Secp256r1 Owner or Admin signers. Ed25519 admins and Spenders can't call it.

TX1 — Authorize (passkey signs)

const prepared = await client.prepareAuthorize({
  payer: payer.publicKey,
  walletPda,
  secp256r1: { credentialIdHash, publicKeyBytes, authorityPda },
  instructions: innerInstructions,              // payload to pre-authorize
});

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

await sendAndConfirm(connection, instructions, [payer]);

TX2 — ExecuteDeferred (any signer)

const { instructions } = await client.executeDeferred({
  payer: payer.publicKey,
  walletPda,
  deferredExecPda,
  instructions: innerInstructions,              // MUST match TX1's instructions
});

Hashes must match

The program verifies TX2's instructions + accounts hash matches what TX1 recorded. Any mismatch is rejected with DeferredHashMismatch (0xbc7).

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.

Reclaim an expired authorization

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

const { instructions } = await client.reclaimDeferred({
  payer: payer.publicKey,
  deferredExecPda,
  refundDestination: payer.publicKey,
});

Only the original payer can reclaim, and only after the deferred account expires.


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.


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.

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.
0xbc7DeferredHashMismatchTX2 instructions differ from TX1's authorization.

Full map in Troubleshooting › Program errors.