Skip to main content
Client keys bind a signed Global Account action to the customer’s current device. When a customer authorizes an outbound action, the client generates a key pair and sends the public key to Grid during credential verification. Grid encrypts the short-lived session signing key to that public key. Only the device with the matching private key can decrypt it and sign the action.

What client keys protect

Client keys help ensure that:
  • The session signing key is delivered only to the device that requested it
  • A stolen encrypted session key cannot be used by another device
  • A signed withdrawal or account action is tied to an authenticated customer session

Signing responsibilities

Your frontend or mobile app handles device-local key generation, decryption, and signing. Your backend holds Grid API credentials and brokers requests to Grid. The client should never receive your Grid client secret.

Full signing flow

Use the reference below to generate client keys, decrypt session material, and authorize payloadToSign values. 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).
In sandbox, encryptedSessionSigningKey is a stub — random bytes shaped like a real HPKE payload but not encrypted to your clientPublicKey. Decrypt attempts will fail. Skip this step entirely on sandbox and use the literal Grid-Wallet-Signature: sandbox-valid-signature for any signed action (see Authorize a payloadToSign). The decrypt path below applies only to production.

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. Authorize 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.
Always authorize the payload byte-for-byte as returned. Do not re-parse, re-serialize, trim, or normalize whitespace. There are two authorization shapes:
FlowWhat to send in Grid-Wallet-Signature
Quote execution (POST /quotes/{quoteId}/execute)A base64 ECDSA signature over the quote payloadToSign.
Signed retries (POST /auth/credentials, DELETE /auth/credentials/{id}, DELETE /auth/sessions/{id}, POST /internal-accounts/{id}/export)A full API-key stamp over the payloadToSign, built with the session API keypair. Also echo Request-Id from the 202 response.
In sandbox, send Grid-Wallet-Signature: sandbox-valid-signature for any signed account action. Sandbox skips signature and stamp verification, so you don’t need a real session signing key or an extracted payloadToSign. The authorization patterns below apply only to production.

Quote execution signature

For quote execution, sign the quote payloadToSign with the session signing key. The signature is ECDSA over SHA-256, DER-encoded, then base64-encoded.
// 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="

Signed-retry stamp

For signed retries, use the session API keypair to build a full API-key stamp over the payloadToSign. Send that complete stamp as Grid-Wallet-Signature and include the Request-Id returned with the challenge.
curl -X DELETE "https://api.lightspark.com/grid/2025-10-13/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzaWduYXR1cmUiOiIzMDQ1MDIyMTAwLi4uIiwic2NoZW1lIjoiUDI1Nl9FQ0RTQV9TSEEyNTYifQ" \
  -H "Request-Id: 2b1e5a08-9c44-4e91-ae7f-6d0b3f8c1e22"

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.