X-Request-Signature header for all v3 API access. The JWT carries identity and is single-use with a 60-second maximum TTL; the request signature binds the call to that specific JWT, request method, path, query, and body. You mint both per request by signing with the name and privateKey from the API key JSON you download from the OpenFX dashboard.
v3 hardens v2 auth — old code does not work unchanged. Two breaks vs v2:
(1) the JWT TTL ceiling drops from 120 seconds to 60 seconds — JWTs minted
with
exp = iat + 120 now return 401 AUTH_TOKEN_INVALID_CONFIG; (2) every
v3 request now requires an X-Request-Signature ES256 signature header —
calls without it are rejected with 401 AUTH_TOKEN_INVALID. Re-mint with the
helpers below; the API key and key file format are unchanged.JWT shape
A v3 JWT carries the claims and header values below. See Mint a JWT and sign the request for how each language builds these.| Field | Where | Value | Notes |
|---|---|---|---|
aud | claim | ["developer-api:v3"] | Audience identifier; fixed string. |
iss | claim | "openfx" | Issuer; fixed string. |
sub | claim | name from the API key JSON | Format: org/{org-id}/apiKey/{api-key-id}. |
iat | claim | seconds since epoch | Issued-at timestamp. |
nbf | claim | seconds since epoch | Not-before timestamp; usually equal to iat. |
exp | claim | iat + 60 | Max 60 seconds. Larger values return AUTH_TOKEN_INVALID_CONFIG. |
alg | header | "ES256" | Only ES256 is accepted. |
kid | header | name from the API key JSON | Key identifier; same value as sub. |
nonce | header | random UUID or 16 random bytes hex-encoded | Server-enforced unique within a 70-second window. Reuse → 401 AUTH_TOKEN_INVALID (with details.reason: REPLAYED). This same value goes into the request-signature canonical string (binds the signature to the JWT). |
JWTs expire after 60 seconds — mint a fresh one per request, not per session. Replaying a
nonce is rejected with 401 AUTH_TOKEN_INVALID (details.reason: REPLAYED). Setting exp - iat > 60 seconds returns AUTH_TOKEN_INVALID_CONFIG. A missing X-Request-Signature header is likewise rejected with 401 AUTH_TOKEN_INVALID.Request signing
Every v3 request carries anX-Request-Signature header — an ES256 signature, IEEE P-1363 fixed 64-byte r‖s encoding, base64url-encoded — over the canonical string:
<METHOD>\n<CANONICAL_PATH>\n<CANONICAL_QUERY>\n<NONCE>\n<SHA256_HEX(body)>
| Component | How to build it |
|---|---|
METHOD | Uppercase HTTP method (GET, POST). |
CANONICAL_PATH | Run the request path through the 7-step canonicalization below. |
CANONICAL_QUERY | Sort the query parameters ascending by key, URL-encode each key=value pair, join with &. Empty string when there are no query parameters. |
NONCE | The same nonce value placed in the JWT header. Sharing this value between the JWT and the signature binds them together — a captured signature cannot be paired with a different JWT. |
SHA256_HEX(body) | Lowercase hex SHA-256 of the raw request body bytes. An empty body hashes to e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 (the hash of the empty string). |
kid to look up the matching public key and verifies both the JWT signature and the request signature against it.
Path canonicalization (7 steps)
Apply in order; sign the result. These rules are a strict subset of what our ingress (Istio Envoy withpathNormalization: DEFAULT) applies on the server side, so the path you sign is the path the server hashes.
- Empty path becomes
/. - Merge consecutive slashes (
//a///b→/a/b). - Remove dot-segments per RFC 3986 §5.2.4 (
/a/./b/../c→/a/c). - Do NOT percent-decode — preserve
%xxsequences exactly (%2Fstays%2F). - Do NOT re-encode any character that wasn’t already encoded.
- Preserve case (paths are case-sensitive).
- Preserve trailing slash (
/a/and/aare distinct).
Three failure modes you will hit if you skip canonicalization. (1)
URL.pathname (or its language equivalent) collapses dot-segments but does
NOT merge //, so https://api.openfx.com//v3/fx/pairs signs //v3/fx/pairs
while the server hashes /v3/fx/pairs. (2) Re-serializing the JSON body
between hashing and sending produces different bytes — the server’s SHA-256
will not match. Serialize once and pass the same bytes to both the hasher and
the HTTP client. (3) Default ECDSA encoding in most languages is DER; the
server expects IEEE P-1363 fixed 64-byte r‖s. Each sample below uses the
language’s idiomatic path to produce IEEE P-1363 output — keep that, don’t
substitute the default.Why nonce reuse and body binding
- Same nonce in JWT and signature. Reusing the JWT’s
noncevalue as the signature nonce binds the two together. An attacker who exfiltrates a valid signature cannot pair it with a different JWT because the signature’s canonical string includes the original JWT’snonce— verifying against a new JWT (with a new nonce) fails. - Body binding. Including
SHA256_HEX(body)in the canonical string means a captured signature only authorizes the exact request it was signed for. Without this, a stolen bearer token could be paired with an attacker-chosen body until the JWT expired. - Server-side nonce uniqueness. The edge reserves each nonce atomically in Valkey for 70 seconds (60s JWT TTL + 10s clock-skew headroom) before the signature is verified. A replayed nonce is rejected with
401 AUTH_TOKEN_INVALID(details.reason: REPLAYED) before any ECDSA work happens — replay attempts cost the attacker a round-trip and gain nothing.
Mint a JWT and sign the request
Each sample mints a fresh 60s JWT and computes the matchingX-Request-Signature in a single helper. Treat the helper as signRequest({ method, url, body }) => { headers, body } — call it for every request, not once per session.
// Mint a 60s ES256 JWT and compute the matching X-Request-Signature for OpenFX v3.
const { sign: jwtSign } = require("jsonwebtoken");
const { createHash, createSign, randomUUID } = require("node:crypto");
// From the API key JSON downloaded in the dashboard.
const KEY_NAME = "org/{org-id}/apiKey/{api-key-id}"; // `name`
const KEY_SECRET =
"-----BEGIN PRIVATE KEY-----\nXXXX\n-----END PRIVATE KEY-----"; // `privateKey`
// 7-step path canonicalization — see /v3/authentication#request-signing.
function canonicalizePath(rawPath) {
if (!rawPath) return "/";
let path = rawPath.replace(/\/{2,}/g, "/"); // step 2: merge //
const hadTrailingSlash = path.length > 1 && path.endsWith("/");
const input = path.split("/");
const output = [];
for (let i = 0; i < input.length; i++) {
const seg = input[i];
if (seg === "" && i !== 0 && i !== input.length - 1) continue;
if (seg === ".") continue; // step 3: drop "."
if (seg === "..") {
// step 3: pop ".."
if (output.length > 1) output.pop();
continue;
}
output.push(seg); // steps 4,5,6: no decode, no re-encode, preserve case
}
path = output.join("/");
if (path === "") path = "/"; // step 1
if (hadTrailingSlash && !path.endsWith("/")) path += "/"; // step 7
return path;
}
// Mints a fresh JWT and computes the matching X-Request-Signature. Call this
// for every request — both rotate together and must never be reused.
function signRequest({ method, url, body }) {
const now = Math.floor(Date.now() / 1000);
const nonce = randomUUID();
// 1. Mint a 60s JWT. The header `nonce` is reused inside the signature canonical string.
const jwt = jwtSign(
{
aud: ["developer-api:v3"],
iss: "openfx",
sub: KEY_NAME,
iat: now,
nbf: now,
exp: now + 60,
},
KEY_SECRET,
{ algorithm: "ES256", header: { alg: "ES256", typ: "JWT", kid: KEY_NAME, nonce } },
);
// 2. Build the canonical string.
const parsed = new URL(url);
const canonicalPath = canonicalizePath(parsed.pathname);
// Sort by byte/codepoint, NOT localeCompare — server uses raw comparison.
const canonicalQuery = [...parsed.searchParams.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
const bodyBytes = body ?? ""; // serialize once: same bytes go to hasher and HTTP client
const bodyHash = createHash("sha256").update(bodyBytes).digest("hex");
const canonical = [method.toUpperCase(), canonicalPath, canonicalQuery, nonce, bodyHash].join("\n");
// 3. Sign with IEEE P-1363 (fixed 64-byte r‖s) — NOT DER.
const signer = createSign("SHA256");
signer.update(canonical);
signer.end();
const signature = signer.sign({ key: KEY_SECRET, dsaEncoding: "ieee-p1363" }).toString("base64url");
return {
headers: {
Authorization: `Bearer ${jwt}`,
"X-Request-Signature": signature,
"Content-Type": "application/json",
"X-App-Mode": "LIVE" // For sandbox the header value should be "SANDBOX"
},
body: bodyBytes,
};
}
module.exports = { signRequest, canonicalizePath };
if (require.main === module) {
const { headers } = signRequest({
method: "GET",
url: "https://api.openfx.com/v3/fx/pairs",
});
console.log(headers);
}
"""Mint a 60s ES256 JWT and compute the matching X-Request-Signature for OpenFX v3."""
import base64
import hashlib
import secrets
import time
from typing import Optional, Tuple
from urllib.parse import urlsplit, parse_qsl, quote
import jwt
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
# From the API key JSON downloaded in the dashboard.
KEY_NAME = "org/{org-id}/apiKey/{api-key-id}" # `name`
KEY_SECRET_PEM = b"-----BEGIN PRIVATE KEY-----\nXXXX\n-----END PRIVATE KEY-----" # `privateKey`
def canonicalize_path(raw_path: str) -> str:
"""7-step path canonicalization. See /v3/authentication#request-signing."""
if not raw_path:
return "/"
while "//" in raw_path: # step 2: merge consecutive slashes
raw_path = raw_path.replace("//", "/")
had_trailing = len(raw_path) > 1 and raw_path.endswith("/")
segments = raw_path.split("/")
out: list[str] = []
for i, seg in enumerate(segments):
if seg == "" and i not in (0, len(segments) - 1):
continue
if seg == ".": # step 3
continue
if seg == "..": # step 3
if len(out) > 1:
out.pop()
continue
out.append(seg) # steps 4,5,6: no decode, no re-encode, preserve case
path = "/".join(out) or "/"
if had_trailing and not path.endswith("/"): # step 7
path += "/"
return path
def _sign_es256_p1363(private_key, message: bytes) -> bytes:
"""ES256 sign producing IEEE P-1363 fixed 64-byte r‖s output.
cryptography returns DER by default; decode and concat r,s as fixed-width big-endian.
"""
der_sig = private_key.sign(message, ec.ECDSA(hashes.SHA256()))
r, s = decode_dss_signature(der_sig)
return r.to_bytes(32, "big") + s.to_bytes(32, "big")
def sign_request(
method: str, url: str, body: Optional[bytes] = None
) -> Tuple[dict, bytes]:
"""Mints a fresh JWT and computes the matching X-Request-Signature. Call this
for every request — both rotate together and must never be reused."""
body_bytes = body or b"" # serialize once: same bytes to hasher and HTTP client
now = int(time.time())
nonce = secrets.token_hex(16)
# 1. Mint a 60s JWT. Header `nonce` is reused inside the signature canonical string.
payload = {
"aud": ["developer-api:v3"],
"iss": "openfx",
"sub": KEY_NAME,
"iat": now,
"nbf": now,
"exp": now + 60,
}
jwt_token = jwt.encode(
payload,
KEY_SECRET_PEM,
algorithm="ES256",
headers={"alg": "ES256", "typ": "JWT", "kid": KEY_NAME, "nonce": nonce},
)
# 2. Build canonical string.
parsed = urlsplit(url)
canonical_path = canonicalize_path(parsed.path)
params = sorted(
parse_qsl(parsed.query, keep_blank_values=True), key=lambda kv: kv[0]
)
canonical_query = "&".join(
f"{quote(k, safe='')}={quote(v, safe='')}" for k, v in params
)
body_hash = hashlib.sha256(body_bytes).hexdigest()
canonical = "\n".join(
[method.upper(), canonical_path, canonical_query, nonce, body_hash]
).encode("utf-8")
# 3. ES256 sign producing IEEE P-1363 fixed 64-byte r‖s — NOT DER.
private_key = serialization.load_pem_private_key(KEY_SECRET_PEM, password=None)
sig_p1363 = _sign_es256_p1363(private_key, canonical)
signature = base64.urlsafe_b64encode(sig_p1363).rstrip(b"=").decode("ascii")
headers = {
"Authorization": f"Bearer {jwt_token}",
"X-Request-Signature": signature,
"Content-Type": "application/json",
"X-App-Mode": "LIVE" # For sandbox the header value should be "SANDBOX"
}
return headers, body_bytes
if __name__ == "__main__":
headers, _ = sign_request("GET", "https://api.openfx.com/v3/fx/pairs")
print(headers)
// Mint a 60s ES256 JWT and compute the matching X-Request-Signature for OpenFX v3.
// Uses only the built-in JDK security APIs — no external dependency.
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.KeyFactory;
import java.security.Signature;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.*;
public class SignRequest {
// From the API key JSON downloaded in the dashboard.
static final String KEY_NAME = "org/{org-id}/apiKey/{api-key-id}"; // `name`
static final String KEY_SECRET_PEM =
"-----BEGIN PRIVATE KEY-----\nXXXX\n-----END PRIVATE KEY-----"; // `privateKey`
public static void main(String[] args) throws Exception {
Map<String, String> headers = signRequest(
"GET", "https://api.openfx.com/v3/fx/pairs", new byte[0]);
System.out.println(headers);
}
/** 7-step path canonicalization. See /v3/authentication#request-signing. */
static String canonicalizePath(String rawPath) {
if (rawPath == null || rawPath.isEmpty()) return "/";
while (rawPath.contains("//")) rawPath = rawPath.replace("//", "/"); // step 2
boolean hadTrailing = rawPath.length() > 1 && rawPath.endsWith("/");
String[] segments = rawPath.split("/");
List<String> out = new ArrayList<>();
for (int i = 0; i < segments.length; i++) {
String seg = segments[i];
if (seg.isEmpty() && i != 0 && i != segments.length - 1) continue;
if (seg.equals(".")) continue; // step 3
if (seg.equals("..")) { // step 3
if (out.size() > 1) out.remove(out.size() - 1);
continue;
}
out.add(seg); // steps 4,5,6: no decode, no re-encode, preserve case
}
String path = String.join("/", out);
if (path.isEmpty()) path = "/";
if (hadTrailing && !path.endsWith("/")) path += "/"; // step 7
return path;
}
static ECPrivateKey loadPrivateKey(String pem) throws Exception {
String cleaned = pem.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] der = Base64.getDecoder().decode(cleaned);
KeyFactory kf = KeyFactory.getInstance("EC");
return (ECPrivateKey) kf.generatePrivate(new PKCS8EncodedKeySpec(der));
}
/** RFC 3986 percent-encoding. URLEncoder.encode is form-urlencoded
* (space -> "+"); the spec requires "%20". */
static String pctEncode(String s) {
return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20");
}
static String base64UrlNoPad(byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
/** ES256 sign producing IEEE P-1363 fixed 64-byte r‖s — NOT DER. */
static byte[] signEs256P1363(ECPrivateKey key, byte[] message) throws Exception {
Signature sig = Signature.getInstance("SHA256withECDSA");
sig.initSign(key);
sig.update(message);
return derToP1363(sig.sign());
}
static byte[] derToP1363(byte[] der) {
int offset = 2; // SEQUENCE tag + length
offset++; // INTEGER tag
int rLen = der[offset++] & 0xff;
byte[] r = Arrays.copyOfRange(der, offset, offset + rLen);
offset += rLen;
offset++; // INTEGER tag
int sLen = der[offset++] & 0xff;
byte[] s = Arrays.copyOfRange(der, offset, offset + sLen);
byte[] out = new byte[64];
System.arraycopy(toFixed32(r), 0, out, 0, 32);
System.arraycopy(toFixed32(s), 0, out, 32, 32);
return out;
}
static byte[] toFixed32(byte[] value) {
int start = 0;
while (start < value.length - 32 && value[start] == 0) start++;
byte[] trimmed = Arrays.copyOfRange(value, start, value.length);
if (trimmed.length == 32) return trimmed;
byte[] out = new byte[32];
System.arraycopy(trimmed, 0, out, 32 - trimmed.length, trimmed.length);
return out;
}
static String signJwtEs256(String keyName, ECPrivateKey key, String nonce, long now) throws Exception {
String headerJson = "{\"alg\":\"ES256\",\"typ\":\"JWT\",\"kid\":\"" + keyName + "\",\"nonce\":\"" + nonce + "\"}";
String payloadJson = "{\"aud\":[\"developer-api:v3\"],\"iss\":\"openfx\",\"sub\":\"" + keyName
+ "\",\"iat\":" + now + ",\"nbf\":" + now + ",\"exp\":" + (now + 60) + "}";
String encodedHeader = base64UrlNoPad(headerJson.getBytes(StandardCharsets.UTF_8));
String encodedPayload = base64UrlNoPad(payloadJson.getBytes(StandardCharsets.UTF_8));
String signingInput = encodedHeader + "." + encodedPayload;
byte[] sig = signEs256P1363(key, signingInput.getBytes(StandardCharsets.UTF_8));
return signingInput + "." + base64UrlNoPad(sig);
}
/** Mints a fresh JWT and computes the matching X-Request-Signature. Call this
* for every request — both rotate together and must never be reused. */
static Map<String, String> signRequest(String method, String urlStr, byte[] body) throws Exception {
long now = Instant.now().getEpochSecond();
byte[] nonceBytes = new byte[16];
new java.security.SecureRandom().nextBytes(nonceBytes);
StringBuilder nonceHex = new StringBuilder();
for (byte b : nonceBytes) nonceHex.append(String.format("%02x", b));
String nonce = nonceHex.toString();
ECPrivateKey key = loadPrivateKey(KEY_SECRET_PEM);
String jwtToken = signJwtEs256(KEY_NAME, key, nonce, now);
URI uri = URI.create(urlStr);
String canonicalPath = canonicalizePath(uri.getPath());
String rawQuery = uri.getQuery();
String canonicalQuery = "";
if (rawQuery != null && !rawQuery.isEmpty()) {
String[] pairs = rawQuery.split("&");
List<String> encoded = new ArrayList<>();
for (String p : pairs) {
String[] kv = p.split("=", 2);
encoded.add(pctEncode(kv[0]) + "=" + pctEncode(kv.length > 1 ? kv[1] : ""));
}
Collections.sort(encoded);
canonicalQuery = String.join("&", encoded);
}
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
byte[] bodyHashBytes = sha256.digest(body);
StringBuilder bodyHashHex = new StringBuilder();
for (byte b : bodyHashBytes) bodyHashHex.append(String.format("%02x", b));
String canonical = String.join("\n", method.toUpperCase(), canonicalPath, canonicalQuery, nonce, bodyHashHex.toString());
byte[] sigP1363 = signEs256P1363(key, canonical.getBytes(StandardCharsets.UTF_8));
String signature = base64UrlNoPad(sigP1363);
Map<String, String> headers = new LinkedHashMap<>();
headers.put("Authorization", "Bearer " + jwtToken);
headers.put("X-Request-Signature", signature);
headers.put("Content-Type", "application/json");
headers.put("X-App-Mode", "LIVE"); // For sandbox the header value should be "SANDBOX"
return headers;
}
}
// Mint a 60s ES256 JWT and compute the matching X-Request-Signature for OpenFX v3.
// Requires OpenSSL. On macOS with Homebrew, add -I/opt/homebrew/include
// -L/opt/homebrew/lib (Apple Silicon) or -I/usr/local/include -L/usr/local/lib
// (Intel) — OpenSSL headers aren't on the default search path there.
#include <openssl/pem.h>
#include <openssl/evp.h>
#include <openssl/sha.h>
#include <openssl/ecdsa.h>
#include <openssl/rand.h>
#include <iostream>
#include <iomanip>
#include <sstream>
#include <string>
#include <vector>
#include <chrono>
#include <stdexcept>
#include <algorithm>
#include <cctype>
struct SignedRequest {
std::string authHeader;
std::string sigHeader;
};
// From the API key JSON downloaded in the dashboard.
static const char* KEY_NAME = "org/{org-id}/apiKey/{api-key-id}"; // `name`
static const char* KEY_SECRET_PEM =
"-----BEGIN PRIVATE KEY-----\nXXXX\n-----END PRIVATE KEY-----"; // `privateKey`
std::string b64url(const unsigned char* data, size_t len) {
static const char* tbl = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
int val = 0, valb = -6;
for (size_t i = 0; i < len; i++) {
val = (val << 8) + data[i];
valb += 8;
while (valb >= 0) {
out.push_back(tbl[(val >> valb) & 0x3F]);
valb -= 6;
}
}
if (valb > -6) out.push_back(tbl[((val << 8) >> (valb + 8)) & 0x3F]);
for (auto& c : out) {
if (c == '+') c = '-';
if (c == '/') c = '_';
}
return out;
}
std::string b64url(const std::string& s) {
return b64url(reinterpret_cast<const unsigned char*>(s.data()), s.size());
}
// RFC 3986 percent-encoding: unreserved chars pass through unescaped,
// everything else becomes %XX (uppercase hex) — including space as %20,
// never "+".
std::string pctEncode(const std::string& s) {
static const char* hex = "0123456789ABCDEF";
std::string out;
for (unsigned char c : s) {
if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
out.push_back((char)c);
} else {
out.push_back('%');
out.push_back(hex[(c >> 4) & 0xF]);
out.push_back(hex[c & 0xF]);
}
}
return out;
}
// Sort the query parameters ascending by key and percent-encode each
// key=value pair. See /v3/authentication#request-signing.
std::string buildCanonicalQuery(const std::string& query) {
if (query.empty()) return "";
std::vector<std::pair<std::string, std::string>> pairs;
size_t start = 0;
while (start <= query.size()) {
size_t amp = query.find('&', start);
std::string pair = query.substr(start, amp == std::string::npos ? std::string::npos : amp - start);
size_t eq = pair.find('=');
if (eq == std::string::npos) {
pairs.push_back({pair, ""});
} else {
pairs.push_back({pair.substr(0, eq), pair.substr(eq + 1)});
}
if (amp == std::string::npos) break;
start = amp + 1;
}
std::stable_sort(pairs.begin(), pairs.end(),
[] (const auto& a, const auto& b) { return a.first < b.first; });
std::string out;
for (size_t i = 0; i < pairs.size(); i++) {
if (i > 0) out += "&";
out += pctEncode(pairs[i].first) + "=" + pctEncode(pairs[i].second);
}
return out;
}
std::string sha256hex(const std::string& data) {
unsigned char hash[SHA256_DIGEST_LENGTH];
SHA256(reinterpret_cast<const unsigned char*>(data.data()), data.size(), hash);
std::ostringstream oss;
for (int i = 0; i < SHA256_DIGEST_LENGTH; i++)
oss << std::hex << std::setfill('0') << std::setw(2) << (int)hash[i];
return oss.str();
}
// 7-step path canonicalization. See /v3/authentication#request-signing.
std::string canonicalizePath(std::string rawPath) {
if (rawPath.empty()) return "/";
size_t pos;
while ((pos = rawPath.find("//")) != std::string::npos) rawPath.replace(pos, 2, "/"); // step 2
bool hadTrailing = rawPath.size() > 1 && rawPath.back() == '/';
std::vector<std::string> raw;
{ std::string tmp; for (char c : rawPath) { if (c == '/') { raw.push_back(tmp); tmp.clear(); } else tmp += c; } raw.push_back(tmp); }
std::vector<std::string> out;
for (size_t i = 0; i < raw.size(); i++) {
const std::string& s = raw[i];
if (s.empty() && i != 0 && i != raw.size() - 1) continue;
if (s == ".") continue; // step 3
if (s == "..") { if (out.size() > 1) out.pop_back(); continue; } // step 3
out.push_back(s); // steps 4,5,6: no decode, no re-encode, preserve case
}
std::string path;
for (size_t i = 0; i < out.size(); i++) { path += out[i]; if (i + 1 < out.size()) path += "/"; }
if (path.empty()) path = "/";
if (hadTrailing && (path.empty() || path.back() != '/')) path += "/"; // step 7
return path;
}
// ES256 sign producing IEEE P-1363 fixed 64-byte r‖s — NOT DER.
std::vector<unsigned char> signEs256P1363(EVP_PKEY* pkey, const std::string& message) {
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
if (!ctx) {
throw std::runtime_error("Could not allocate EVP_MD_CTX.");
}
EVP_DigestSignInit(ctx, nullptr, EVP_sha256(), nullptr, pkey);
EVP_DigestSignUpdate(ctx, message.data(), message.size());
size_t sigLen = 0;
EVP_DigestSignFinal(ctx, nullptr, &sigLen);
std::vector<unsigned char> der(sigLen);
EVP_DigestSignFinal(ctx, der.data(), &sigLen);
EVP_MD_CTX_free(ctx);
const unsigned char* p = der.data();
ECDSA_SIG* sig = d2i_ECDSA_SIG(nullptr, &p, (long)sigLen);
if (!sig) {
throw std::runtime_error("Could not parse the DER-encoded ECDSA signature.");
}
const BIGNUM* r; const BIGNUM* s;
ECDSA_SIG_get0(sig, &r, &s);
std::vector<unsigned char> out(64, 0);
BN_bn2binpad(r, out.data(), 32);
BN_bn2binpad(s, out.data() + 32, 32);
ECDSA_SIG_free(sig);
return out;
}
EVP_PKEY* loadPrivateKey(const std::string& pem) {
BIO* bio = BIO_new_mem_buf(pem.data(), (int)pem.size());
if (!bio) {
throw std::runtime_error("Could not allocate BIO for the private key PEM.");
}
EVP_PKEY* pkey = PEM_read_bio_PrivateKey(bio, nullptr, nullptr, nullptr);
BIO_free(bio);
if (!pkey) {
throw std::runtime_error(
"Could not parse the private key PEM. Replace KEY_SECRET_PEM with the "
"real `privateKey` value from your API key JSON.");
}
return pkey;
}
static std::string signJwtEs256(const std::string& keyName, EVP_PKEY* pkey, const std::string& nonce, long now) {
std::ostringstream headerJson;
headerJson << "{\"alg\":\"ES256\",\"typ\":\"JWT\",\"kid\":\"" << keyName << "\",\"nonce\":\"" << nonce << "\"}";
std::ostringstream payloadJson;
payloadJson << "{\"aud\":[\"developer-api:v3\"],\"iss\":\"openfx\",\"sub\":\"" << keyName
<< "\",\"iat\":" << now << ",\"nbf\":" << now << ",\"exp\":" << (now + 60) << "}";
std::string encHeader = b64url(headerJson.str());
std::string encPayload = b64url(payloadJson.str());
std::string signingInput = encHeader + "." + encPayload;
auto sig = signEs256P1363(pkey, signingInput);
return signingInput + "." + b64url(sig.data(), sig.size());
}
// Mints a fresh JWT and computes the matching X-Request-Signature. Call this
// for every request — both rotate together and must never be reused.
SignedRequest signRequest(const std::string& method, const std::string& urlPath,
const std::string& query, const std::string& body) {
EVP_PKEY* pkey = loadPrivateKey(KEY_SECRET_PEM);
auto now = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count();
unsigned char nonceBytes[16];
RAND_bytes(nonceBytes, 16);
std::ostringstream nonceHex;
for (int i = 0; i < 16; i++) nonceHex << std::hex << std::setfill('0') << std::setw(2) << (int)nonceBytes[i];
std::string nonce = nonceHex.str();
std::string jwt = signJwtEs256(KEY_NAME, pkey, nonce, now);
std::string canonicalPath = canonicalizePath(urlPath);
std::string canonicalQuery = buildCanonicalQuery(query);
std::string bodyHash = sha256hex(body);
std::string canonical = method + "\n" + canonicalPath + "\n" + canonicalQuery + "\n" + nonce + "\n" + bodyHash;
auto reqSig = signEs256P1363(pkey, canonical);
std::string signature = b64url(reqSig.data(), reqSig.size());
EVP_PKEY_free(pkey);
return { "Bearer " + jwt, signature };
}
#ifndef SIGN_REQUEST_NO_MAIN
int main() {
try {
SignedRequest req = signRequest("GET", "/v3/fx/pairs", "", "");
std::cout << "Authorization: " << req.authHeader << std::endl;
std::cout << "X-Request-Signature: " << req.sigHeader << std::endl;
std::cout << "X-App-Mode: " << "LIVE" << std::endl; // For sandbox the header value should be "SANDBOX"
return 0;
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << std::endl;
return 1;
}
}
#endif
// Mint a 60s ES256 JWT and compute the matching X-Request-Signature for OpenFX v3.
package auth
import (
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/pem"
"fmt"
"net/url"
"sort"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// From the API key JSON downloaded in the dashboard.
const (
KeyName = "org/{org-id}/apiKey/{api-key-id}" // `name`
KeySecretPEM = "-----BEGIN PRIVATE KEY-----\nXXXX\n-----END PRIVATE KEY-----" // `privateKey`
)
// SignedRequest carries the headers and body bytes to send.
type SignedRequest struct {
Headers map[string]string
Body []byte
}
// CanonicalizePath performs the 7-step path canonicalization documented at
// /v3/authentication#request-signing.
func CanonicalizePath(raw string) string {
if raw == "" {
return "/"
}
for strings.Contains(raw, "//") { // step 2
raw = strings.ReplaceAll(raw, "//", "/")
}
hadTrailing := len(raw) > 1 && strings.HasSuffix(raw, "/")
segments := strings.Split(raw, "/")
out := make([]string, 0, len(segments))
for i, seg := range segments {
if seg == "" && i != 0 && i != len(segments)-1 {
continue
}
if seg == "." { // step 3
continue
}
if seg == ".." { // step 3
if len(out) > 1 {
out = out[:len(out)-1]
}
continue
}
out = append(out, seg) // steps 4,5,6: no decode, no re-encode, preserve case
}
path := strings.Join(out, "/")
if path == "" {
path = "/"
}
if hadTrailing && !strings.HasSuffix(path, "/") { // step 7
path += "/"
}
return path
}
func parsePrivateKey(pemStr string) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, fmt.Errorf("no PEM block found — replace KeySecretPEM with your real privateKey")
}
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
ecKey, ok := k.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("not an ECDSA key")
}
return ecKey, nil
}
func pctEncode(s string) string {
// RFC 3986 percent-encoding. Go's url.QueryEscape is form-urlencoded
// (space -> "+"); the spec requires "%20".
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}
func buildCanonicalQuery(values url.Values) string {
keys := make([]string, 0, len(values))
for k := range values {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(values))
for _, k := range keys {
for _, v := range values[k] {
parts = append(parts, pctEncode(k)+"="+pctEncode(v))
}
}
return strings.Join(parts, "&")
}
// SignRequest mints a fresh JWT and computes the matching X-Request-Signature.
// Call this for every request — both rotate together and must never be reused.
func SignRequest(method, rawURL string, body []byte) (*SignedRequest, error) {
privateKey, err := parsePrivateKey(KeySecretPEM)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
nonceBytes := make([]byte, 16)
if _, err := rand.Read(nonceBytes); err != nil {
return nil, fmt.Errorf("nonce: %w", err)
}
nonce := hex.EncodeToString(nonceBytes)
now := time.Now().Unix()
// 1. Mint a 60s JWT.
claims := jwt.MapClaims{
"aud": []string{"developer-api:v3"},
"iss": "openfx",
"sub": KeyName,
"iat": now,
"nbf": now,
"exp": now + 60,
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = KeyName
token.Header["nonce"] = nonce
jwtStr, err := token.SignedString(privateKey)
if err != nil {
return nil, fmt.Errorf("sign jwt: %w", err)
}
// 2. Build canonical string.
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("parse url: %w", err)
}
canonicalPath := CanonicalizePath(parsed.EscapedPath())
canonicalQuery := buildCanonicalQuery(parsed.Query())
bodyHashBytes := sha256.Sum256(body)
bodyHashHex := hex.EncodeToString(bodyHashBytes[:])
canonical := strings.Join([]string{
strings.ToUpper(method), canonicalPath, canonicalQuery, nonce, bodyHashHex,
}, "\n")
// 3. ES256 sign producing IEEE P-1363 fixed 64-byte r||s — NOT DER.
sigP1363, err := signEs256P1363(privateKey, []byte(canonical))
if err != nil {
return nil, err
}
signature := base64.RawURLEncoding.EncodeToString(sigP1363)
return &SignedRequest{
Headers: map[string]string{
"Authorization": "Bearer " + jwtStr,
"X-Request-Signature": signature,
"Content-Type": "application/json",
"X-App-Mode": "LIVE", // For sandbox the header value should be "SANDBOX"
},
Body: body,
}, nil
}
func signEs256P1363(privateKey *ecdsa.PrivateKey, message []byte) ([]byte, error) {
digest := sha256.Sum256(message)
r, s, err := ecdsa.Sign(rand.Reader, privateKey, digest[:])
if err != nil {
return nil, fmt.Errorf("ecdsa sign: %w", err)
}
curveBytes := (privateKey.Curve.Params().BitSize + 7) / 8 // 32 for P-256
sig := make([]byte, 2*curveBytes)
r.FillBytes(sig[:curveBytes])
s.FillBytes(sig[curveBytes:])
return sig, nil
}
Retrieve the key name from the
name parameter and the private key from the
privateKey parameter in the JSON file downloaded during API key creation.
The org ID is encoded inside name (format: org/{org - id}/apiKey/ {api - key - id}).Common errors
When token minting, signing, or use goes wrong, you’ll see one of theseAUTH_* codes. Full catalog in Errors → Auth.
| Code | When it fires | Fix |
|---|---|---|
AUTH_TOKEN_MISSING | Authorization header absent | Add the header. |
AUTH_TOKEN_INVALID | JWT signature/format invalid, or the X-Request-Signature is missing or doesn’t verify (details.reason: SIGNATURE_INVALID — common causes: DER instead of IEEE P-1363, body re-serialized between hash and send, path not canonicalized, query not sorted, nonce in signature doesn’t match JWT header), or the JWT nonce was replayed within the server window (details.reason: REPLAYED). | Re-mint with ES256 and the correct key. For signature failures: re-check IEEE P-1363 encoding, freeze the body bytes once, run the path through canonicalizePath, and confirm you reused the JWT’s nonce inside the canonical string. For a replayed nonce: generate a fresh nonce for every request. |
AUTH_TOKEN_EXPIRED | exp is in the past | Re-mint. Check NTP clock sync on the host. |
AUTH_TOKEN_INVALID_CONFIG | exp - iat > 60s or other unexpected claim config | Re-mint with exp = iat + 60 (or shorter). |
AUTH_API_KEY_INVALID | API key revoked or not found | Generate a new key in the dashboard. |
AUTH_FORBIDDEN_IP | Source IP not in the key’s allowlist | Update the IP allowlist for the key. |
AUTH_INSUFFICIENT_SCOPE | Key missing the required scope (e.g. trade) | Generate a key with the needed scope. |
What’s next
Quickstart
Mint a token and make your first trade end-to-end.
Environments
Switch between Sandbox and Live modes with the same key flow.
Errors
AUTH_TOKEN_* codes and how to recover.Rate limiting
Per-key and per-org limits, plus the headers to watch.