API authentication
Backend wallet operations require JWT-based authentication using your wallet secret. This page explains how to generate authentication tokens manually if you're not using the @openfort/openfort-node.
Overview
Openfort uses asymmetric JWT authentication with ECDSA P-256 (ES256) signatures. Your wallet secret is a private key that signs requests, and Openfort verifies them using the corresponding public key stored on our servers.
This approach provides:
- Non-repudiation: Only you can sign requests with your private key
- Request integrity: The request body is cryptographically bound to the token
- Replay protection: Each token has a unique identifier and short expiration
Wallet secret format
Wallet secrets are ECDSA private keys conforming to the secp256r1 (P-256) elliptic curve. When you generate a wallet secret in the dashboard, you receive a base64-encoded DER-formatted private key.
JWT structure
Each request to wallet endpoints must include a JWT in the X-Wallet-Auth header. The token consists of three parts:
<header>.<payload>.<signature>Header
{
"alg": "ES256",
"typ": "JWT"
}| Field | Description |
|---|---|
alg | Must be ES256 (ECDSA using P-256 curve and SHA-256) |
typ | Must be JWT |
Payload
{
"iat": 1706745600,
"nbf": 1706745600,
"jti": "550e8400e29b41d4a716446655440000",
"uris": ["POST api.openfort.io/v2/accounts/backend"],
"reqHash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
}| Claim | Description | Validation |
|---|---|---|
iat | Issued at (Unix timestamp) | Must not be in the future; must not be older than 2 minutes |
nbf | Not before (Unix timestamp) | Must not be in the future (30-second clock skew allowed) |
jti | JWT ID (unique nonce) | Must be unique per request (prevents replay attacks) |
uris | Request URIs being signed | Must include the method, host, and path of the request |
reqHash | SHA-256 hash of the request body | Must match the hash of the canonically sorted JSON body (omit if no body) |
Computing the request hash
The reqHash claim ensures request integrity. To compute it:
- Canonicalize the JSON body: Sort all keys alphabetically (recursively for nested objects)
- Stringify without whitespace: Convert to a compact JSON string
- Hash with SHA-256: Compute the hash and encode as lowercase hexadecimal
For requests without a body (like GET requests), use an empty string for the hash computation.
Code example
import { createHash, randomBytes } from 'crypto';
import { importPKCS8, SignJWT } from 'jose';
// Your wallet secret from the dashboard (base64-encoded DER private key)
const WALLET_SECRET = process.env.OPENFORT_WALLET_SECRET!;
function sortObjectKeys(obj: unknown): unknown {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(sortObjectKeys);
}
const sorted: Record<string, unknown> = {};
for (const key of Object.keys(obj as Record<string, unknown>).sort()) {
sorted[key] = sortObjectKeys((obj as Record<string, unknown>)[key]);
}
return sorted;
}
function computeRequestHash(body: Record<string, unknown>): string {
const sorted = sortObjectKeys(body);
const canonical = JSON.stringify(sorted);
return createHash('sha256').update(canonical).digest('hex');
}
function generateNonce(): string {
return randomBytes(16).toString('hex');
}
async function generateWalletAuthToken(
method: string,
host: string,
path: string,
body?: Record<string, unknown>
): Promise<string> {
// Convert base64 DER to PEM format for jose
const derBuffer = Buffer.from(WALLET_SECRET, 'base64');
const pemKey = `-----BEGIN PRIVATE KEY-----\n${derBuffer
.toString('base64')
.match(/.{1,64}/g)
?.join('\n')}\n-----END PRIVATE KEY-----`;
const ecKey = await importPKCS8(pemKey, 'ES256');
const now = Math.floor(Date.now() / 1000);
const uri = `${method.toUpperCase()} ${host}${path}`;
const claims: Record<string, unknown> = {
uris: [uri],
};
// Only include reqHash if there's request data
if (body && Object.keys(body).length > 0) {
claims.reqHash = computeRequestHash(body);
}
const token = await new SignJWT(claims)
.setProtectedHeader({ alg: 'ES256', typ: 'JWT' })
.setIssuedAt(now)
.setNotBefore(now)
.setJti(generateNonce())
.sign(ecKey);
return token;
}
// Example: Create an EVM backend account
async function createAccount() {
const method = 'POST';
const host = 'api.openfort.io';
const path = '/v2/accounts/backend';
const body = { chainType: 'EVM', name: 'MyWallet' };
const walletAuthToken = await generateWalletAuthToken(method, host, path, body);
// Sort the body keys to match the hash computation
const sortedBody = sortObjectKeys(body);
const response = await fetch(`https://${host}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENFORT_SECRET_KEY}`,
'X-Wallet-Auth': walletAuthToken,
},
body: JSON.stringify(sortedBody),
});
return response.json();
}Request headers
When making authenticated requests to wallet endpoints, include these headers:
| Header | Description |
|---|---|
Authorization | Your API secret key: Bearer sk_test_... or Bearer sk_live_... |
X-Wallet-Auth | The JWT generated with your wallet secret |
Content-Type | application/json for requests with a body |
Security best practices
- Never expose in client-side code: Wallet secrets must only be used in server-side environments
- Use environment variables: Store secrets using your platform's secret management (AWS Secrets Manager, Google Secret Manager, HashiCorp Vault)
- Rotate periodically: Rotate secrets as part of your security policy and immediately if you suspect compromise
- Unique JTI per request: Always generate a fresh nonce for each request to prevent replay attacks
- Sort request body keys: The request body sent to the API must be sorted alphabetically to match the hash in the JWT
Troubleshooting
Invalid signature
- Verify you're using the correct wallet secret
- Ensure the private key is properly decoded from base64 and converted to PEM format
- Confirm the algorithm is
ES256(notES384orES512)
Request hash mismatch
- Ensure JSON keys are sorted alphabetically at all nesting levels
- Use compact JSON encoding (no whitespace)
- The request body sent to the API must be sorted the same way as when computing the hash
- Only include
reqHashif the request has a body with data
Token too old
- Check that your server clock is synchronized (use NTP)
- Generate tokens immediately before making requests
- The
iatclaim must not be older than 2 minutes
Replay attack detected
- Each request must have a unique
jticlaim - Don't reuse tokens across multiple requests