Where to store private keys in your app
The safest place for a private key is somewhere your app can ask for a signature without ever reading the key. Here is where that is on a phone, on a server, and for a crypto wallet your app controls.
The safest place for a private key is somewhere your app can ask for a signature without ever reading the key. On a phone, that means the iOS Keychain or the Android Keystore. On a server, it means a key management service, a hardware security module, or a hosted signer. For a crypto wallet that your app controls, Lit Protocol keeps the key inside a secure enclave and runs your signing rule in the same enclave. Your server holds an API key that can ask for signatures, and that is all it holds.
I will go through the places people usually put keys, and then show how the Lit version works.
Places the key should not go
Do not put a private key in your source code, even in a private repo. Git history keeps it after you delete it. Do not put it in a frontend bundle or in browser storage like localStorage, because anyone who opens your app can read it. Be careful with environment variables too. They are better than source code, but they get baked into container images, printed in crash reports, and read by other processes running as the same user. And check your logs. A key that shows up once in a debug line is a key you have to rotate.
On a user's phone
If the key belongs to the user, keep it on their device. The iOS Keychain and the Android Keystore are backed by secure hardware on most devices, and apps cannot read each other's entries. One detail matters for crypto. The secure hardware on phones signs with a different curve than Ethereum and Bitcoin use, so a wallet app usually generates the wallet key, encrypts it with a key held by that hardware, and stores the encrypted copy. The wallet key only exists in memory while it is signing.
On a server
For a key your server uses, the standard answer is a key management service like AWS KMS or Google Cloud KMS. Both can generate a secp256k1 key, which is the kind Ethereum and Bitcoin use. The key stays in the provider's hardware. Your server sends a hash and gets back a signature. This is a big improvement over an environment variable, because nobody can copy the key.
There is one limit worth knowing about. A KMS signs whatever hash it is given. It does not know what a transaction is, so it cannot check the amount or the recipient. The only thing between a compromised server and a signature is the IAM policy that lets your server call the KMS. The post on signing from your backend without exposing the private key compares a KMS with the other places a server's key can live.
A signer that checks each transaction
On Lit, the key lives in an enclave, and so does a JavaScript function that decides whether to sign. That function is called a Lit Action. This one only sends USDC on Base to addresses on an allowlist, and never more than 1,000 USDC at a time.
// payout.js
const USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
const ALLOWED = ["0xVendorOne...", "0xVendorTwo..."].map(a => a.toLowerCase());
const MAX = 1000_000000; // 1,000 USDC, which has 6 decimals
async function main({ pkpId, to, amount }) {
if (!ALLOWED.includes(to.toLowerCase())) return { sent: false, reason: "recipient not on the allowlist" };
if (Number(amount) > MAX) return { sent: false, reason: "over the limit" };
const provider = new ethers.providers.StaticJsonRpcProvider("https://mainnet.base.org");
const wallet = new ethers.Wallet(await Lit.Actions.getPrivateKey({ pkpId }), provider);
const usdc = new ethers.Contract(USDC, ["function transfer(address,uint256) returns (bool)"], wallet);
const tx = await usdc.transfer(to, amount);
return { sent: true, hash: tx.hash };
}The allowlist and the limit are part of the code. Lit knows the action by its content hash, so if someone changes the allowlist, it becomes a different action with a different hash, and your server's key is not allowed to run it.
Your server calls it like this.
jq -n --rawfile code payout.js '{code: $code, js_params: {pkpId: "0xYourWallet", to: "0xVendorOne...", amount: "250000000"}}' \
| curl -s -X POST https://api.chipotle.litprotocol.com/core/v1/lit_action \
-H "X-Api-Key: $USAGE_KEY" -H "Content-Type: application/json" -d @-The USAGE_KEY is an API key that can run actions in one group and do nothing else. It cannot read the private key, add a new action, or change the group. If someone steals it, they can send payments of up to 1,000 USDC each to your vendors until the wallet is empty, and spend its ETH on gas while they do it. They cannot do anything else. The wallet also needs a little ETH on Base to pay for gas. The setup calls for the wallet, the group, and the key are in the API reference.
If the key has to be in memory
Sometimes a library needs the raw key and you cannot avoid it. In that case, keep the key in a secrets manager and load it when the process starts, instead of keeping it in a file or an image. Run the signing code as its own small service, so the rest of your app never has the key in memory. Keep only a working balance in that wallet and top it up from a wallet that is kept offline. And write down how you would rotate the key before you need to.
Get started
To create an account, go to the Lit dashboard and click Create an account. The quickstart is at developer.litprotocol.com/quickstart, and the two kinds of API keys are explained at developer.litprotocol.com/management/api_keys.