Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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"
}
FieldDescription
algMust be ES256 (ECDSA using P-256 curve and SHA-256)
typMust be JWT

Payload

{
  "iat": 1706745600,
  "nbf": 1706745600,
  "jti": "550e8400e29b41d4a716446655440000",
  "uris": ["POST api.openfort.io/v2/accounts/backend"],
  "reqHash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
}
ClaimDescriptionValidation
iatIssued at (Unix timestamp)Must not be in the future; must not be older than 2 minutes
nbfNot before (Unix timestamp)Must not be in the future (30-second clock skew allowed)
jtiJWT ID (unique nonce)Must be unique per request (prevents replay attacks)
urisRequest URIs being signedMust include the method, host, and path of the request
reqHashSHA-256 hash of the request bodyMust 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:

  1. Canonicalize the JSON body: Sort all keys alphabetically (recursively for nested objects)
  2. Stringify without whitespace: Convert to a compact JSON string
  3. 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:

HeaderDescription
AuthorizationYour API secret key: Bearer sk_test_... or Bearer sk_live_...
X-Wallet-AuthThe JWT generated with your wallet secret
Content-Typeapplication/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 (not ES384 or ES512)

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 reqHash if 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 iat claim must not be older than 2 minutes

Replay attack detected

  • Each request must have a unique jti claim
  • Don't reuse tokens across multiple requests
Copyright © 2023-present Alamas Labs, Inc