# 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](https://github.com/openfort-xyz/openfort-node).

:::tip
If you're using the [Openfort Node.js SDK](https://github.com/openfort-xyz/openfort-node), authentication is handled automatically.
This guide is for developers who need to make direct API calls or use a language without an official SDK.
:::

## 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:

```text
<header>.<payload>.<signature>
```

### Header

```json
{
  "alg": "ES256",
  "typ": "JWT"
}
```

| Field | Description |
| --- | --- |
| `alg` | Must be `ES256` (ECDSA using P-256 curve and SHA-256) |
| `typ` | Must be `JWT` |

### Payload

```json
{
  "iat": 1706745600,
  "nbf": 1706745600,
  "jti": "550e8400e29b41d4a716446655440000",
  "uris": ["POST api.openfort.io/v2/accounts"],
  "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:

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

```typescript
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 Ethereum backend account
async function createAccount() {
  const method = 'POST';
  const host = 'api.openfort.io';
  const path = '/v2/accounts';
  const body = { accountType: 'Smart', chainType: 'EVM', user: 'usr_...' };

  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

:::warning
Your wallet secret is equivalent to the password for your backend wallets. Anyone with access to it can authorize transactions. Follow these practices to keep it secure.
:::

* **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
