Skip to main content
Every signed Global Account action uses two key pairs: This page covers generating the client key pair, sending the public key to your backend, holding (or, in the legacy flow, decrypting) the session signing key, and signing payloads. Everything here runs on the client; your integrator backend only relays opaque byte strings.

Client-held session key

In the recommended client-held-key flow, the key pair you generate in step 1 is your session key: you keep the private key on the device and use it directly to sign account actions. There is no session key to receive or decrypt, and Grid never transmits one — so the verify, refresh, and challenge responses omit encryptedSessionSigningKey. This is how EMAIL_OTP and SMS_OTP have always worked; OAUTH and PASSKEY now follow the same model. To use it, send clientPublicKey in compressed SEC1 form — a 66-character hex string starting with 02 or 03 (the prefix followed by the 32-byte X coordinate). Grid treats a compressed key as a request to adopt it as the session signing key and returns an AuthSession with no encryptedSessionSigningKey. You then skip steps 2–3 and sign each payloadToSign with the private key you kept, exactly as in step 4. Because this key signs directly, generate it as a P-256 signing key (ECDSA) — not the ECDH recipient key the step 1 samples below create for the legacy decrypt flow.
Sending clientPublicKey in uncompressed SEC1 form (130 hex characters, 04 prefix) selects the deprecated legacy flow, in which Grid seals the session signing key to your public key and returns it as encryptedSessionSigningKey for you to decrypt — steps 2 and 3 below. New integrations should send the compressed key and hold their own session key.

1. Generate a client key pair

Generate a fresh P-256 key pair for every authentication, session refresh, and wallet export. The public key is sent to Grid as clientPublicKey — for PASSKEY credentials this happens on POST /auth/credentials/{id}/challenge; for EMAIL_OTP and OAUTH it happens on POST /auth/credentials/{id}/verify; for session refresh it goes on both /auth/sessions/{id}/refresh calls; for wallet export it goes on both /export calls. 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 through your integrator backend. For the recommended client-held-key flow, send the compressed form — a 66-character string starting with 02 or 03 (see Client-held session key). For the legacy flow, send the uncompressed form — a 130-character string starting with 04, which the Web Crypto, iOS, and Android APIs below produce natively.
For local development, you can generate a P-256 key pair from the command line:
The private key must not leave the device. Your integrator backend only ever sees publicKeyHex.

Encrypt the OTP code (EMAIL_OTP only)

EMAIL_OTP credentials never send the OTP code in plaintext. Instead, the client HPKE-encrypts the code (together with its publicKeyHex) to an enclave key, so the code is unreadable in transit and Grid is only a pass-through. Grid returns an otpEncryptionTargetBundle whenever it initiates or reissues an OTP challenge, including POST /auth/credentials/{id}/challenge and add-EMAIL_OTP signed-retry responses. First-time EMAIL_OTP wallet bootstrap registration can omit it; if the registration response has no bundle, call POST /auth/credentials/{id}/challenge for that credential before verifying. The bundle is a signed enclave bundle whose data field is hex-encoded JSON carrying the enclave’s HPKE target key as targetPublic. Pull out targetPublic, HPKE-encrypt { otp_code, public_key } to it, and submit the library’s { encappedPublic, ciphertext } output as encryptedOtpBundle on POST /auth/credentials/{id}/verify. Use an HPKE library so you don’t hand-roll the suite, info, or AAD. The helper below assumes your crypto layer returns the JSON string Grid expects for encryptedOtpBundle.
Web (TypeScript)
A production client should also verify the bundle’s dataSignature against its enclaveQuorumPublic before trusting targetPublic.
The private key of the pair you generated in step 1 stays on the device — it becomes the session signing key once verification completes (see Sign a payloadToSign), so EMAIL_OTP responses omit encryptedSessionSigningKey.

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

Steps 2 and 3 apply to the legacy flow only (uncompressed clientPublicKey). In the recommended client-held-key flow the response has no encryptedSessionSigningKey to receive or decrypt — skip to step 4.
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). For HPKE itself, uncompress the encapsulated key to 65-byte SEC1 form, use Grid’s session-bundle HPKE info value, and authenticate with AAD encappedPublicUncompressed || recipientPublicKeyUncompressed.
Sandbox supports the same decryptable encryptedSessionSigningKey format for production-shaped PASSKEY and OAUTH tests. The legacy Grid-Wallet-Signature: sandbox-valid-signature shortcut is still accepted, but using real session bundles and stamps catches client-side format bugs before production.

3. Decrypt the session signing key

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 a Global Account) — 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 /auth/sessions/{id}/refresh, POST /internal-accounts/{id}/export, PATCH /internal-accounts/{id}, PATCH /customers/{id} for tied EMAIL_OTP email updates — all 202 response bodies.
Stamp the payload byte-for-byte as returned (do not re-parse, re-serialize, or trim whitespace). The session signing key produces a Grid wallet signature: derive its compressed P-256 public key, sign the payload with the private scalar, then base64url-encode the signature envelope:
Pass that full stamp as the Grid-Wallet-Signature header on the retry (and, for endpoints that use it, echo the 202 requestId as Request-Id).
In sandbox, send Grid-Wallet-Signature: sandbox-valid-signature for any signed account action. Sandbox skips the ECDSA check, so you don’t need a real session signing key or an extracted payloadToSign. The signing pattern below applies only to production.
Your backend adds the stamp to the retry request:

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.