---
title: "Authentication"
description: "In this guide, we'll look at how authentication works. Fystack API uses HMAC-based authentication to securely verify your API requests."
url: https://docs.fystack.io/authentication
last_updated: 2026-10-15
---

> ## Documentation index
> Fetch the complete documentation index at: https://docs.fystack.io/llms.txt
> Use it to discover all available pages before exploring further.

# Authentication

You'll need to authenticate your requests to access any of the endpoints in the Fystack API. In this guide, we'll look at how authentication works. Fystack uses HMAC-based authentication to securely verify the authenticity of your API requests.

## HMAC Authentication

Fystack API uses a secure HMAC-based authentication system that requires you to sign your requests with your API secret. Each request needs three authentication headers:

```bash
ACCESS-API-KEY: your_api_key
ACCESS-TIMESTAMP: current_unix_timestamp
ACCESS-SIGN: base64_encoded_hmac_signature
```

The HMAC signature is generated by combining the HTTP method, request path, timestamp, and request body (if applicable), then signing it with your API secret.

## Example Request with Authentication Headers

Here's an example of how to make an authenticated request to the Fystack API:

```bash
curl https://api.fystack.io/api/v1/wallets \
-H "ACCESS-API-KEY: your_api_key" \
-H "ACCESS-TIMESTAMP: 1667836889" \
-H "ACCESS-SIGN: YourBase64EncodedSignature=="
```

## Computing the ACCESS-SIGN

To compute the HMAC signature, you'll need to:

1. Create a canonical string from request parameters in fixed order: `method`, `path`, `timestamp`, `body`
2. Sign this string with your API secret using HMAC-SHA256
3. Hex-encode the HMAC digest, then base64-encode the hex string

Always keep your API secret safe and reset it if you suspect it has been compromised. Here's how to compute the signature in each language:

```typescript
import CryptoJS from 'crypto-js'

function computeSignature(
  apiSecret: string,
  method: string,
  path: string,
  timestamp: string,
  body: string = ''
): string {
  // Build canonical string in exact order
  const canonical = `method=${method}&path=${path}&timestamp=${timestamp}&body=${body}`

  // HMAC-SHA256 -> hex -> base64
  const hexDigest = CryptoJS.HmacSHA256(canonical, apiSecret)
    .toString(CryptoJS.enc.Hex)

  return btoa(hexDigest)
}

// Example usage
const timestamp = Math.floor(Date.now() / 1000).toString()
const signature = computeSignature(
  'your_api_secret', 'GET',
  '/api/v1/workspaces/your_workspace_id/wallets',
  timestamp
)

const headers = {
  'ACCESS-API-KEY': 'your_api_key',
  'ACCESS-TIMESTAMP': timestamp,
  'ACCESS-SIGN': signature
}
```

```go
import (
  "crypto/hmac"
  "crypto/sha256"
  "encoding/base64"
  "encoding/hex"
  "fmt"
  "strconv"
  "time"
)

func computeSignature(apiSecret string, method, path, timestamp, body string) string {
  // Build canonical string in fixed order
  message := fmt.Sprintf("method=%s&path=%s&timestamp=%s&body=%s",
    method, path, timestamp, body)

  // Create HMAC-SHA256
  h := hmac.New(sha256.New, []byte(apiSecret))
  h.Write([]byte(message))
  hexSig := hex.EncodeToString(h.Sum(nil))

  // Base64-encode the hex string
  return base64.StdEncoding.EncodeToString([]byte(hexSig))
}

// Usage
ts := strconv.FormatInt(time.Now().Unix(), 10)
signature := computeSignature(
  apiSecret, "GET",
  "/api/v1/workspaces/your_workspace_id/wallets",
  ts, "",
)
```

```php
<?php
function computeSignature($apiSecret, $method, $path, $timestamp, $body = '') {
  // Build canonical string in fixed order
  $message = "method={$method}&path={$path}&timestamp={$timestamp}&body={$body}";

  // Create HMAC-SHA256 hex digest
  $hexSig = hash_hmac('sha256', $message, $apiSecret);

  // Base64-encode the hex string
  return base64_encode($hexSig);
}

// Usage
$timestamp = time();
$signature = computeSignature(
  $apiSecret, 'GET',
  '/api/v1/workspaces/your_workspace_id/wallets',
  $timestamp, ''
);

$headers = [
  'ACCESS-API-KEY: ' . $apiKey,
  'ACCESS-TIMESTAMP: ' . $timestamp,
  'ACCESS-SIGN: ' . $signature
];
```

```python
import hmac
import hashlib
import time
import base64

def compute_signature(api_secret: str, method: str, path: str, timestamp: str, body: str = '') -> str:
  # Build canonical string in fixed order
  message = f"method={method}&path={path}&timestamp={timestamp}&body={body}"

  # Create HMAC-SHA256 hex digest
  hex_sig = hmac.new(
    api_secret.encode(),
    message.encode(),
    hashlib.sha256
  ).hexdigest()

  # Base64-encode the hex string
  return base64.b64encode(hex_sig.encode()).decode()

# Usage
timestamp = str(int(time.time()))
signature = compute_signature(
  api_secret, 'GET',
  '/api/v1/workspaces/your_workspace_id/wallets',
  timestamp, ''
)

headers = {
  'ACCESS-API-KEY': api_key,
  'ACCESS-TIMESTAMP': timestamp,
  'ACCESS-SIGN': signature
}
```

```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;

public static String computeSignature(String apiSecret, String method, String path, String timestamp, String body) throws Exception {
  // Build canonical string in fixed order
  String canonical = String.format("method=%s&path=%s&timestamp=%s&body=%s", method, path, timestamp, body);

  // Create HMAC-SHA256
  Mac mac = Mac.getInstance("HmacSHA256");
  mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
  byte[] digest = mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8));

  // Hex-encode the digest
  StringBuilder hexDigest = new StringBuilder();
  for (byte b : digest) {
    hexDigest.append(String.format("%02x", b));
  }

  // Base64-encode the hex string
  return Base64.getEncoder().encodeToString(hexDigest.toString().getBytes(StandardCharsets.UTF_8));
}

// Usage
String timestamp = String.valueOf(Instant.now().getEpochSecond());
String signature = computeSignature(
  apiSecret, "GET",
  "/api/v1/workspaces/your_workspace_id/wallets",
  timestamp, ""
);

Map<String, String> headers = Map.of(
  "ACCESS-API-KEY", apiKey,
  "ACCESS-TIMESTAMP", timestamp,
  "ACCESS-SIGN", signature
);
```

### Request signing process

1. Build the canonical string in fixed order: `method={METHOD}&path={PATH}&timestamp={TIMESTAMP}&body={BODY}`
   - `method`: HTTP method in uppercase (GET, POST, etc.)
   - `path`: Request path (e.g. `/api/v1/workspaces/{id}/wallets`)
   - `timestamp`: UNIX timestamp in seconds
   - `body`: JSON-stringified request body, or empty string for GET requests

2. Create the HMAC-SHA256 signature:
   - Sign the canonical string with your API secret
   - Hex-encode the digest
   - Base64-encode the hex string

3. Add required headers:
   - `ACCESS-API-KEY`: Your API key
   - `ACCESS-TIMESTAMP`: The timestamp used in the signature
   - `ACCESS-SIGN`: The base64-encoded signature

> **Warning.** Keep your API secret secure and never share it. The API key can be shared but the secret should remain private.

## Building the Body String

`body` in the canonical string must be the **exact bytes you transmit**, for both HMAC and Ed25519. For a `GET` request, or any request with no body, that's an empty string, never `"{}"` or `null`.

The mistake to avoid: serializing the payload twice, once to sign, once when your HTTP client sends it. If what you signed doesn't byte-for-byte match what the server received, `ValidateAccessSign` fails, even though the payload is logically the same JSON. Serialize once, then reuse that exact string both to build the canonical string and as the literal request body, don't hand your HTTP client the original object/dict/map and trust a second, independent serialization call to reproduce it exactly. Depending on the language this either fails loudly (some HTTP clients reject or mishandle a non-string body outright) or fails silently as a hard-to-debug signature mismatch (if the two serialization calls happen to diverge, from a library upgrade, a different default option, or unusual values like `NaN`), so don't rely on either outcome, just don't serialize twice.

```javascript
const payload = { name: 'my-integration' }
const body = JSON.stringify(payload) // serialize once

// sign() uses `body` in the canonical string, exactly as below
const signature = computeSignature(apiSecret, 'POST', path, timestamp, body)

await fetch(url, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', /* ...auth headers */ },
  // `fetch` requires body to already be a string (or Blob/ArrayBuffer/etc),
  // it will not JSON-serialize `payload` for you, pass `body`, not `payload`
  body
})
```

```go
payload := map[string]string{"name": "my-integration"}
bodyBytes, _ := json.Marshal(payload) // serialize once
body := string(bodyBytes)

signature := computeSignature(apiSecret, "POST", path, timestamp, body)

req, _ := http.NewRequest("POST", url, bytes.NewReader(bodyBytes)) // same bytes
```

```python
import json
import requests

payload = {'name': 'my-integration'}
body = json.dumps(payload)  # serialize once

signature = compute_signature(api_secret, 'POST', path, timestamp, body)

# Pass data=body (the exact string you signed). Passing json=payload
# instead would have requests() serialize the dict itself, a second,
# independent call that isn't guaranteed to match what you signed
requests.post(url, data=body, headers={'Content-Type': 'application/json', **auth_headers})
```

```php
<?php
$payload = ['name' => 'my-integration'];
$body = json_encode($payload); // serialize once

$signature = computeSignature($apiSecret, 'POST', $path, $timestamp, $body);

curl_setopt($ch, CURLOPT_POSTFIELDS, $body); // same string
```

```java
String body = objectMapper.writeValueAsString(payload); // serialize once

String signature = computeSignature(apiSecret, "POST", path, timestamp, body);

HttpRequest request = HttpRequest.newBuilder()
  .uri(URI.create(url))
  .POST(HttpRequest.BodyPublishers.ofString(body)) // same string
  .build();
```

## Ed25519 Signing

If your API key uses a [client key pair](https://docs.fystack.io/api-keys.md#client-key-pair) instead of an HMAC secret, sign the same canonical string with your Ed25519 private key instead of computing an HMAC. Two differences from the HMAC flow:

- Sign the canonical string bytes directly, there's no separate hashing step, Ed25519 does that internally.
- Base64-encode the raw 64-byte signature straight away, there's no hex step.

```
ACCESS-SIGN = base64( Ed25519_Sign(privateKey, canonical_string) )
```

If you're using `@fystack/sdk`, pass a `signer` (`LocalPrivateKeySigner` or `AwsKmsSigner`) on `credentials` and it does this for you, see [API Keys](https://docs.fystack.io/api-keys.md#client-key-pair). The examples below are for signing without the SDK, in other languages.

```javascript
const crypto = require('crypto');

function computeSignature(privateKeyPem, method, path, timestamp, body = '') {
  const canonical = `method=${method}&path=${path}&timestamp=${timestamp}&body=${body}`;

  const privateKey = crypto.createPrivateKey(privateKeyPem);
  // No digest algorithm, Ed25519 signs the message directly
  const signature = crypto.sign(null, Buffer.from(canonical), privateKey);

  return signature.toString('base64');
}

// Usage
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = computeSignature(
  process.env.PRIVATE_KEY_PEM, 'GET',
  '/api/v1/workspaces/your_workspace_id/wallets',
  timestamp
);
```

```go
import (
  "crypto/ed25519"
  "crypto/x509"
  "encoding/base64"
  "encoding/pem"
  "errors"
  "fmt"
)

func computeSignature(privateKeyPEM []byte, method, path, timestamp, body string) (string, error) {
  canonical := fmt.Sprintf("method=%s&path=%s&timestamp=%s&body=%s", method, path, timestamp, body)

  block, _ := pem.Decode(privateKeyPEM)
  if block == nil {
    return "", errors.New("invalid PEM private key")
  }
  key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
  if err != nil {
    return "", err
  }
  edKey, ok := key.(ed25519.PrivateKey)
  if !ok {
    return "", errors.New("not an Ed25519 private key")
  }

  // ed25519.Sign hashes internally, sign the canonical string directly
  signature := ed25519.Sign(edKey, []byte(canonical))
  return base64.StdEncoding.EncodeToString(signature), nil
}
```

```python
import time
import base64
from cryptography.hazmat.primitives.serialization import load_pem_private_key

def compute_signature(private_key_pem: bytes, method: str, path: str, timestamp: str, body: str = '') -> str:
  # Build canonical string in fixed order
  canonical = f"method={method}&path={path}&timestamp={timestamp}&body={body}"

  private_key = load_pem_private_key(private_key_pem, password=None)
  # .sign() on an Ed25519PrivateKey signs the message directly, no digest argument
  signature = private_key.sign(canonical.encode())

  return base64.b64encode(signature).decode()

# Usage
timestamp = str(int(time.time()))
signature = compute_signature(
  private_key_pem, 'GET',
  '/api/v1/workspaces/your_workspace_id/wallets',
  timestamp
)
```

```php
<?php
// Requires PHP 8.4+ (Ed25519 key support landed in ext-openssl in 8.4)
function computeSignature($privateKeyPem, $method, $path, $timestamp, $body = '') {
  $canonical = "method={$method}&path={$path}&timestamp={$timestamp}&body={$body}";

  $privateKey = openssl_pkey_get_private($privateKeyPem);
  // The digest argument is ignored for Ed25519 keys
  openssl_sign($canonical, $signature, $privateKey, 0);

  return base64_encode($signature);
}

// Usage
$timestamp = time();
$signature = computeSignature(
  $privateKeyPem, 'GET',
  '/api/v1/workspaces/your_workspace_id/wallets',
  $timestamp
);
```

> **Warning.** Never send your Ed25519 private key to Fystack or embed it in client-side/browser code, only the public key is registered. For production, keep the key in a hardware-backed store like AWS KMS instead of a PEM file, see [Client Key Pair](https://docs.fystack.io/api-keys.md#client-key-pair).

### Signing via AWS KMS (non-SDK)

If you're using `@fystack/sdk` on Node.js, `AwsKmsSigner` already does this for you, see [Client Key Pair](https://docs.fystack.io/api-keys.md#client-key-pair). For every other backend, or if you just want to see the mechanism, the same approach works with any language's AWS SDK: call KMS `Sign` with `MessageType: RAW` and `SigningAlgorithm: ED25519_SHA_512` over the raw canonical string, then base64-encode the returned signature bytes. This is exactly what `AwsKmsSigner` does internally, the Node.js example below is that same logic written out directly.

> **Warning.** `MessageType` must be `RAW`, not `DIGEST`. KMS's `ED25519_SHA_512` algorithm expects the unhashed message and hashes it internally, matching how Ed25519 works everywhere else on this page. Passing `DIGEST` signs a different, non-interoperable value.

```javascript
import { KMSClient, SignCommand } from '@aws-sdk/client-kms'

async function computeSignature(kmsClient, keyId, method, path, timestamp, body = '') {
  const canonical = `method=${method}&path=${path}&timestamp=${timestamp}&body=${body}`

  const response = await kmsClient.send(new SignCommand({
    KeyId: keyId,
    Message: Buffer.from(canonical, 'utf8'),
    MessageType: 'RAW',
    SigningAlgorithm: 'ED25519_SHA_512'
  }))

  return Buffer.from(response.Signature).toString('base64')
}
```

```go
import (
  "context"
  "encoding/base64"
  "fmt"

  "github.com/aws/aws-sdk-go-v2/aws"
  "github.com/aws/aws-sdk-go-v2/service/kms"
  "github.com/aws/aws-sdk-go-v2/service/kms/types"
)

func computeSignature(ctx context.Context, client *kms.Client, keyId, method, path, timestamp, body string) (string, error) {
  canonical := fmt.Sprintf("method=%s&path=%s&timestamp=%s&body=%s", method, path, timestamp, body)

  out, err := client.Sign(ctx, &kms.SignInput{
    KeyId:            aws.String(keyId),
    Message:          []byte(canonical),
    MessageType:      types.MessageTypeRaw,
    SigningAlgorithm: types.SigningAlgorithmSpecEd25519Sha512,
  })
  if err != nil {
    return "", err
  }

  return base64.StdEncoding.EncodeToString(out.Signature), nil
}
```

```java
import software.amazon.awssdk.core.SdkBytes;
import software.amazon.awssdk.services.kms.KmsClient;
import software.amazon.awssdk.services.kms.model.MessageType;
import software.amazon.awssdk.services.kms.model.SignRequest;
import software.amazon.awssdk.services.kms.model.SignResponse;
import software.amazon.awssdk.services.kms.model.SigningAlgorithmSpec;
import java.util.Base64;

public String computeSignature(KmsClient kmsClient, String keyId, String method, String path, String timestamp, String body) {
  String canonical = String.format("method=%s&path=%s&timestamp=%s&body=%s", method, path, timestamp, body);

  SignRequest request = SignRequest.builder()
    .keyId(keyId)
    .message(SdkBytes.fromUtf8String(canonical))
    .messageType(MessageType.RAW)
    .signingAlgorithm(SigningAlgorithmSpec.ED25519_SHA_512)
    .build();

  SignResponse response = kmsClient.sign(request);
  return Base64.getEncoder().encodeToString(response.signature().asByteArray());
}
```

```python
import base64

def compute_signature(kms_client, key_id: str, method: str, path: str, timestamp: str, body: str = '') -> str:
  canonical = f"method={method}&path={path}&timestamp={timestamp}&body={body}"

  response = kms_client.sign(
    KeyId=key_id,
    Message=canonical.encode(),
    MessageType='RAW',
    SigningAlgorithm='ED25519_SHA_512'
  )

  return base64.b64encode(response['Signature']).decode()
```

```php
<?php
use Aws\Kms\KmsClient;

function computeSignature(KmsClient $kms, string $keyId, string $method, string $path, string $timestamp, string $body = ''): string {
  $canonical = "method={$method}&path={$path}&timestamp={$timestamp}&body={$body}";

  $result = $kms->sign([
    'KeyId' => $keyId,
    'Message' => $canonical,
    'MessageType' => 'RAW',
    'SigningAlgorithm' => 'ED25519_SHA_512',
  ]);

  // $result['Signature'] is already raw bytes, the SDK base64-decodes it for you
  return base64_encode($result['Signature']);
}
```

`kmsClient`/`client`/`$kms` above is whatever KMS client your language's AWS SDK already gives you, configured however you normally configure it. See [Client Key Pair](https://docs.fystack.io/api-keys.md#client-key-pair) for the general local-vs-production pattern (point it at LocalStack/ministack with static test credentials locally, drop both in production and let the IAM role supply credentials), the same idea applies regardless of language, just through that SDK's own config options.
