# API keys

Lighter never signs orders with your Openfort wallet's EVM key. Every trading action is signed by a separate **API key** — a Poseidon-Schnorr keypair, not an EVM key — that your Openfort wallet authorizes once via a plain `personal_sign`. The result is a delegatable key a server can hold with bounded risk.

## Roles

| Role | Can trade | Can withdraw | Can register/revoke keys |
|------|-----------|---------------|----------------------------|
| Openfort wallet (L1 owner) | No — never signs orders | Yes, to its own address only | Yes, via `personal_sign` |
| API key (Poseidon-Schnorr) | Yes | Yes, but **only to the L1 owner's own address** | No |

The withdrawal transaction Lighter's API exposes carries no destination address at all — the protocol routes every withdrawal to the account's registered L1 owner. That's what makes it safe to hold an API key server-side: even a fully compromised key can't redirect funds anywhere but back to the user's own wallet.

## Registration flow (ChangePubKey)

Registering a key is called `ChangePubKey` in `lighter-go`. The signature mechanics aren't documented on `apidocs.lighter.xyz` — this is sourced directly from `lighter-go`'s `types/txtypes/change_pub_key.go` and `utils.go` (commit `c26ac340ce5d2e237c555949b6ab0927bd09e0df`).

:::steps
### Generate a keypair and build the message (server)

The vendored WASM signer generates a fresh Poseidon-Schnorr keypair and renders the exact template your L1 wallet needs to sign:

```ts
import { generateApiKey, createSigningClient, signChangePubKey, loadSigner } from './signer/signer.js'

await loadSigner()
const { privateKey, publicKey } = generateApiKey()

// SignChangePubKey is self-signed by the key being installed — the client
// must be created from the freshly generated key, not any existing key.
createSigningClient(apiBaseUrl, privateKey, chainId, apiKeyIndex, accountIndex)

const { messageToSign } = signChangePubKey({
  pubKeyHex: publicKey,
  nonce, // from GET /api/v1/nextNonce for this (accountIndex, apiKeyIndex)
  apiKeyIndex,
  accountIndex,
})
```

`messageToSign` is always exactly this template (values substituted):

```text
Register Lighter Account

pubkey: 0x<pubkey>
nonce: <hex10>
account index: <hex10>
api key index: <hex10>
Only sign this message for a trusted client!
```

### Sign with the Openfort wallet

This is a plain **EIP-191 `personal_sign`** — not EIP-712 typed data. Any Openfort wallet type that exposes `signMessage` works the same way:

```ts
// Backend wallet
const l1Sig = await account.signMessage({ message: messageToSign })
```

```ts
// Embedded wallet (EIP-1193 provider) — see Client-side SDKs
const l1Sig = await provider.request({
  method: 'personal_sign',
  params: [messageToSign, walletAddress],
})
```

### Submit — the server adopts the key automatically

Splice the L1 signature into the pending `ChangePubKey` transaction and submit it. The recipe server then **adopts the key it just registered**: it hot-swaps its signing client in memory and persists the credentials into its own env file, so restarts survive. Key material never leaves the server and there is no manual copy step:

```ts
const txInfo = JSON.parse(pendingTxInfo)
txInfo.L1Sig = l1Sig
await sendTx(config, txType, JSON.stringify(txInfo))

adoptServerKey(config, { accountIndex, apiKeyIndex, privateKey })
```
:::

:::info
The L2 side of `ChangePubKey` is self-signed by the very API key being installed (Poseidon-Schnorr), resolving what looks like a chicken-and-egg problem: the server creates a signing client from the fresh private key immediately after generating it, then that same client signs the L2 portion of its own registration transaction.
:::

## Key indices

* API key indices **2–254** are available to third-party integrations. Indices 0 and 1 are reserved for Lighter's own web and mobile interfaces — pick 2 or above to avoid colliding with a user's existing session.
* Each `(accountIndex, apiKeyIndex)` pair has its own nonce, fetched from `GET /api/v1/nextNonce`.
* An account holds **one key per index**, across many indices simultaneously (list them with `GET /api/v1/apikeys`) — for example, one index per server or environment. Registering at an occupied index replaces that key; see Rotation below.

## Read-only auth tokens

Authenticated read endpoints (like active orders) use a bearer token signed by the API key, not the registration signature:

```ts
const authToken = createAuthToken(deadlineUnixSeconds, apiKeyIndex, accountIndex)

const response = await fetch(`${apiBaseUrl}/api/v1/accountActiveOrders?account_index=${accountIndex}`, {
  headers: { authorization: authToken },
})
```

Auth tokens expire after a maximum of **8 hours** — regenerate one per request or cache it with a shorter TTL than the deadline you sign into it.

## Rotation — every submit replaces the key

`ChangePubKey` is a **rotation, not an addition**: submitting it registers the new public key at that `(accountIndex, apiKeyIndex)` slot, and whatever key was registered there before stops verifying immediately — its next signature fails with Lighter error `21120 invalid signature`. Verified live; this cuts both ways:

* **Revoking a compromised key is instant**: register a fresh key at the same index and the old one is dead. No dedicated revoke transaction exists or is needed.
* **Signing the registration twice in a row silently strands whoever adopted the first key.** Embedded wallets sign `personal_sign` without a native prompt, so a stray tap is a rotation. The recipe defends against this in layers: a confirmation dialog before any re-authorization, a double-sign guard, automatic server adoption of the newest key, and a startup self-test that detects a stale key (a real, harmless transaction classified by the exact `21120` code) and routes the app back to re-authorize instead of dead-ending.

Keys at *different* indices are independent — rotating index 2 doesn't affect a key registered at index 3.

## Best practices

* **Never let the L1 owner's wallet sign orders directly** — that's not how Lighter is designed, and it defeats the point of delegating to a bounded-risk key.
* **Restrict the L1 wallet's `personal_sign` to the exact `ChangePubKey` template** with an Openfort [policy](/docs/recipes/lighter/policies) — see the `evmMessage` regex criterion.
* **Pick a stable `apiKeyIndex` per server/environment** (2, 3, 4, ...) rather than generating a new index on every deploy — makes it easy to tell which key is active.
* **Rotate before an 8-hour auth token would otherwise force a manual refresh in an unattended process** — regenerate tokens proactively rather than reacting to expiry errors.
