Save and load a PEM (dev only)
A PEM file is the quick dev-wallet format: write a key, load it back, done. But
it stores the secret key unencrypted, anyone who reads the file has the key.
This recipe covers the full round-trip via both the Account API and the
lower-level UserPem class, fully offline.
A PEM stores the secret key in plaintext. Never put a mainnet or funded key in a PEM. For anything real, use an encrypted keystore.
Prerequisites
- Node.js >= 20.19.0.
- Nothing else. This recipe generates its own throwaway key and writes to a temp directory it cleans up.
Install
mkdir pem-save-load
cd pem-save-load
# Create the project files shown on this page.
npm install
npm run build
npm start
Complete project files omitted from the main walkthrough
{
"name": "cookbook-recipe-pem-save-load",
"version": "1.0.0",
"private": true,
"description": "Cookbook recipe — save a wallet to a PEM file (dev only, unencrypted) and load it back via Account and UserPem. Fully offline: local file I/O only.",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit --strict"
},
"dependencies": {
"@multiversx/sdk-core": "15.4.1",
"bignumber.js": "9.3.1",
"protobufjs": "7.6.5"
},
"devDependencies": {
"@types/node": "20.19.43",
"typescript": "5.9.3"
},
"engines": {
"node": ">=20.19.0"
}
}
{
"compilerOptions": {
"target": "ES2022",
"lib": ["esnext"],
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
The code
// src/index.ts — save a wallet to a PEM file and load it back, via both the
// Account API (saveToPem / newFromPem) and the lower-level UserPem class.
//
// PEM IS FOR DEVELOPMENT AND TESTING ONLY. A PEM file stores the secret key
// UNENCRYPTED — anyone who reads the file has the key. Never put a
// mainnet/funded key in a PEM. For anything real, use an encrypted keystore
// (see the "Save and load a keystore" recipe).
//
// Fully offline. The only I/O is to a throwaway temp directory (cleaned up
// at the end); no devnet, no network. Fresh keys each run.
//
// Grounded in the installed @multiversx/sdk-core v15.4.1: Account.saveToPem /
// Account.newFromPem (accounts/account.d.ts) and UserPem (wallet/userPem.d.ts).
import { Account, Mnemonic, UserPem } from '@multiversx/sdk-core';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
async function main(): Promise<void> {
const dir = mkdtempSync(join(tmpdir(), 'mvx-pem-'));
try {
// === 1. Write a PEM from an Account. ===
const account = Account.newFromMnemonic(Mnemonic.generate().toString(), 0);
const expectedAddress = account.address.toBech32();
const pemPath = join(dir, 'wallet.pem');
account.saveToPem(pemPath);
// A PEM is plain text: a base64 body between BEGIN/END markers, labeled
// with the bech32 address. The secret key sits in that body, unencrypted.
const firstLine = readFileSync(pemPath, 'utf8').split('\n')[0] ?? '';
console.log(`1. Wrote ${pemPath.split('/').pop() ?? ''}. First line: ${firstLine}`);
// === 2. Load it back with Account.newFromPem — this one is ASYNC. ===
// Unlike newFromMnemonic / newFromKeystore / newFromKeypair (all sync),
// newFromPem returns a Promise and must be awaited.
const loaded = await Account.newFromPem(pemPath);
console.log(`2. Account.newFromPem address matches: ${loaded.address.toBech32() === expectedAddress}`);
// === 3. UserPem is the lower-level view of the same file. ===
// It exposes the label, the secret key, and the public key directly —
// useful when you want the key material rather than a full Account.
const pem = UserPem.fromFile(pemPath);
console.log(`3. UserPem.fromFile label is the address: ${pem.label === expectedAddress}`);
console.log(` UserPem secret key matches the Account's: ${pem.secretKey.hex() === account.secretKey.hex()}`);
// === 4. Build a PEM from raw key material, no Account needed. ===
// new UserPem(label, secretKey).save(path) is the write side of UserPem.
const rebuilt = new UserPem(expectedAddress, account.secretKey);
const rebuiltPath = join(dir, 'rebuilt.pem');
rebuilt.save(rebuiltPath);
const rebuiltAccount = await Account.newFromPem(rebuiltPath);
console.log(`4. Rebuilt PEM round-trips to the same address: ${rebuiltAccount.address.toBech32() === expectedAddress}`);
// A PEM file can hold several keys; UserPem.fromFile(path, index) selects
// one, and UserPem.fromFileAll(path) returns them all. Account.newFromPem
// takes the same optional index.
console.log('\nExpected: three "true" confirmations (2, 3, 4) and the matching key line.');
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
main().catch((err: unknown) => {
console.error(err);
process.exitCode = 1;
});
Run it
Keys are generated fresh each run; the confirmations are stable:
1. Wrote wallet.pem. First line: -----BEGIN PRIVATE KEY for erd1lss6vxy7pamd6nflt9zuusxdmqutd0mwzu7nrmxvwax3em0kzgjqv63ssn-----
2. Account.newFromPem address matches: true
3. UserPem.fromFile label is the address: true
UserPem secret key matches the Account's: true
4. Rebuilt PEM round-trips to the same address: true
How it works
Grounded in the installed @multiversx/sdk-core v15.4.1 Account.saveToPem /
Account.newFromPem (accounts/account.d.ts) and UserPem
(wallet/userPem.d.ts):
- Write from an Account.
account.saveToPem(path). The file is plain text, a base64 body betweenBEGIN/ENDmarkers, labeled with the bech32 address. The secret key sits in that body, unencrypted. - Load with
Account.newFromPem, this one is async. It returns aPromiseand must be awaited, unlike the synchronousnewFromMnemonic/newFromKeystore/newFromKeypair. UserPemis the lower-level view.UserPem.fromFile(path)exposeslabel,secretKey, andpublicKeydirectly.- Build a PEM from raw material.
new UserPem(label, secretKey).save(path)is the write side ofUserPem, noAccountrequired.
A PEM can hold several keys; UserPem.fromFile(path, index) selects one and
UserPem.fromFileAll(path) returns them all. Account.newFromPem takes the same
optional index.
Pitfalls
There is no password. Reading the file is reading the private key. Keep PEMs out of version control, shared drives, and anything mainnet. This is the whole reason the keystore format exists.
Forgetting to await newFromPem leaves you holding a Promise<Account>, whose
.address is undefined. This is the single most common PEM mistake.
The bech32 address in the BEGIN ... for erd1... header is a human-readable
hint, not verified against the key on load. The account's real address always
comes from the key, derive it rather than trusting the label.
See also
- Save and load a keystore is the encrypted, production-safe alternative to PEM.
- Create an Account from a KeyPair, secret key, or mnemonic
covers the in-memory constructors, including the async
newFromPemnote. - Manage nonces is the next step once you have a loaded dev account.