Skip to main content
Every signed Embedded Wallet action uses two key pairs:
Key pairWhere it livesWhat it does
Client key pair (P-256)On the customer’s device, generated fresh per verification requestUsed as the HPKE recipient key so Grid can encrypt the session signing key to the client. Ephemeral — one pair per POST /auth/credentials/{id}/verify call.
Session signing key (P-256)Issued by Grid, encrypted to the client public key, decrypted and held on the deviceSigns every wallet action for the lifetime of the session (default 15 minutes).
This page covers generating the client key pair, sending the public key to your backend, decrypting the session signing key, and signing payloads. Everything here runs on the client; your integrator backend only relays opaque byte strings.

1. Generate a client key pair

Generate a fresh P-256 key pair for every POST /auth/credentials/{id}/verify call and for every wallet export. Keep the private key in device-local secure storage (browser IndexedDB gated by Web Crypto’s non-extractable flag, iOS Keychain, Android Keystore). Send the public key hex-encoded — a 130-character string starting with 04 — to your integrator backend, which passes it to Grid as clientPublicKey. The Web Crypto, iOS, and Android APIs shown below all produce this format natively.
For local development, you can generate a P-256 key pair from the command line:
# Private key (PKCS#8 PEM)
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out private.pem

# Public key (SPKI PEM)
openssl pkey -in private.pem -pubout -out public.pem
function bytesToHex(bytes: Uint8Array): string {
  return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}

// Generate a non-extractable P-256 key pair in the browser.
// The private key never leaves Web Crypto; only the public key is exported.
async function generateClientKeyPair(): Promise<{
  keyPair: CryptoKeyPair;
  publicKeyHex: string;
}> {
  const keyPair = await crypto.subtle.generateKey(
    { name: "ECDH", namedCurve: "P-256" },
    false, // private key non-extractable
    ["deriveBits"],
  );

  const raw = new Uint8Array(
    await crypto.subtle.exportKey("raw", keyPair.publicKey),
  );
  // exportKey("raw") returns the 65-byte uncompressed form (0x04 || X || Y).
  const publicKeyHex = bytesToHex(raw);

  return { keyPair, publicKeyHex };
}
The private key must not leave the device. Your integrator backend only ever sees publicKeyHex.

2. Verify the credential and receive the encrypted session signing key

Your client sends publicKeyHex to your integrator backend along with whatever the credential type requires (OTP value, OIDC token, or WebAuthn assertion — see Authentication). Your backend calls POST /auth/credentials/{id}/verify and returns the encryptedSessionSigningKey from Grid’s response to the client. Grid encrypts the session signing key with HPKE (RFC 9180) using the suite:
  • KEM: DHKEM(P-256, HKDF-SHA256)
  • KDF: HKDF-SHA256
  • AEAD: AES-256-GCM
The wire format is a base58check string. Decoded, the payload is a 33-byte compressed P-256 encapsulated public key followed by AES-256-GCM ciphertext (ciphertext || 16-byte auth tag).

3. Decrypt the session signing key

// npm i @hpke/core @hpke/dhkem-p256 bs58check
import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core";
import { DhkemP256HkdfSha256 } from "@hpke/dhkem-p256";
import bs58check from "bs58check";

async function decryptSessionSigningKey(
  clientKeyPair: CryptoKeyPair,
  encryptedSessionSigningKey: string,
): Promise<Uint8Array> {
  const payload = bs58check.decode(encryptedSessionSigningKey);
  const enc = payload.slice(0, 33); // compressed P-256 encapsulated public key
  const ciphertext = payload.slice(33);

  const suite = new CipherSuite({
    kem: new DhkemP256HkdfSha256(),
    kdf: new HkdfSha256(),
    aead: new Aes256Gcm(),
  });

  const recipient = await suite.createRecipientContext({
    recipientKey: clientKeyPair.privateKey,
    enc,
  });
  const plaintext = await recipient.open(ciphertext);
  return new Uint8Array(plaintext); // 32-byte P-256 session private key (scalar)
}
The plaintext is a 32-byte P-256 private scalar. Treat it as the session signing key for the rest of the session.

4. Sign a payloadToSign

Grid returns payloadToSign strings from several endpoints:
  • POST /quotes (when the source is an Embedded Wallet) — the quote’s paymentInstructions[].accountOrWalletInfo.payloadToSign.
  • POST /auth/credentials (adding an additional credential) — 202 response body.
  • DELETE /auth/credentials/{id}, DELETE /auth/sessions/{id}, POST /internal-accounts/{id}/export — all 202 response bodies.
Sign the payload byte-for-byte as returned (do not re-parse, re-serialize, or trim whitespace). The signature is ECDSA over SHA-256 using the session signing key, DER-encoded, then base64-encoded. Pass it as the Grid-Wallet-Signature header on the retry (and, for endpoints that use it, the Request-Id header echoed back from the 202).
// npm i @noble/curves @noble/hashes
import { p256 } from "@noble/curves/p256";
import { sha256 } from "@noble/hashes/sha256";

function bytesToBase64(bytes: Uint8Array): string {
  let binary = "";
  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary);
}

function signPayload(
  sessionPrivateKeyBytes: Uint8Array, // 32 bytes, from decryptSessionSigningKey
  payloadToSign: string,
): string {
  const digest = sha256(new TextEncoder().encode(payloadToSign));
  const signature = p256.sign(digest, sessionPrivateKeyBytes);
  return bytesToBase64(signature.toDERRawBytes());
}
Your backend adds the signature to the retry request:
curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/execute" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Grid-Wallet-Signature: MEUCIQDx7k2N0aK4p8f3vR9J6yT5wL1mB0sXnG2hQ4vJ8zYkCgIgZ4rP9dT7eWfU3oM6KjR1qSpNvBwL0tXyA2iG8fH5dE="

Session lifetime

Sessions are valid for 15 minutes by default. The AuthSession.expiresAt field tells you exactly when the session signing key stops being accepted. After expiry, the client must re-verify the credential (see Authentication) to obtain a fresh session.
If the device is lost or compromised, the user should add a second credential from a trusted device and revoke the compromised one — see Managing credentials. To end the current browser or app session without touching credentials, see Sessions.