Authentication

Authentication is the first thing to get right — everything else depends on it. Once it clicks it's straightforward, but the details matter, so this doc spends a little extra time on them.

The short version: every request carries a per-request Ed25519 JWT. The JWT signs a SHA-256 hash of the request body, so the signature effectively covers the entire request. There's no long-lived token — you mint one per call.


1. One-time setup with ProphetX

Generate an Ed25519 keypair:

openssl genpkey -algorithm ED25519 -out private-key.pem -outpubkey public-key.pem

Send public-key.pem to your ProphetX contact through a secure channel (Slack for now). You'll receive back:

  • Your ISV UUID. This becomes both the kid in your JWT header and the iss in your claims.
  • Your fundType: STANDING or INDIVIDUAL. This determines how wallets work — see Wallets.

Keep private-key.pem on your server, ideally in a secrets manager. It should never end up in client-side code or version control.


2. The per-user shared secret

The first time you call POST /private/v1/users, the response includes a sharedSecret. It's base64url, no padding, 32 bytes of random. This is the HMAC key for any JWT you'll ever issue on that user's behalf.

Save it immediately. It is not retrievable. If you lose it, you can no longer authenticate as that user — you'd need to delete and recreate the account.

Store it encrypted at rest, indexed by user UUID, and treat it like a password.


3. The JWT structure

Header:

{ "typ": "JWT", "alg": "EdDSA", "kid": "<your-isv-uuid>" }

Claims:

ClaimWhenValue
issalwaysYour ISV UUID. Must equal kid.
audalwaysThe literal string "prophetx".
iatalwaysNow. Within ±30s of the server's clock.
nbfalwaysNow. Same window.
expalwaysiat + N where N < 300. Five-minute hard ceiling.
jtialwaysA unique identifier for this request. Server-side uniqueness is not enforced — make sure your generator produces fresh values.
digestwhen body is non-emptyBase64URL(SHA256(body)), no padding. May be omitted or empty-string on GET requests.
subuser-scoped routesThe user's UUID. Must equal USERID in the URL exactly.
subsiguser-scoped routesBase64URL(HMAC-SHA256(secret, "<sub>:<iat>:<jti>")), no padding. secret is the user's shared secret.
client_ipuser-scoped routesThe end user's IP address (the trader's, not your server's) as a plain IPv4 or IPv6 string — e.g. "203.0.113.7". The gateway's geo gate reads this to enforce licensing by jurisdiction. Ignored on ISV-only routes (where sub is empty).

Sign the JWT with your Ed25519 private key and send it as Authorization: Bearer <jwt>.

client_ip is the trader's IP, not your backend's. Capture it from the request hitting your service (typically X-Forwarded-For behind a proxy, or the socket peer) and pass it through to the JWT you mint on the trader's behalf. A token signed with your server's IP will be rejected with GEO_BLOCKED once geofencing is enabled.


4. Verify your implementation before going to the network

If your code can't reproduce these exact strings, fix that before making real requests — it'll save a lot of guesswork.

Body digest:

body   = {"var":"value"}
digest = c4q8WYBUkCjkEp87BSu8B4lEd3HCzxrsO3KG-A6Tau4

User subject signature:

secret = mCJlmBkB361AsfmFUcn8eyHFJdB8ZjGw13TeAw20p80
data   = user-1:1234:id        (sub : iat : jti)
subsig = yX6IHcu_urfX8zxyhKO2G2JV4Y0S0gOddrp3FMbSP0M

The single most common source of 401s is base64 padding. Both digest and subsig use base64url without padding — strip any trailing = signs.


5. The subUSERID rule

If the URL contains USERID, your JWT's sub claim must equal that USERID. A server-side check enforces this even after the gateway has validated the JWT, so a JWT that's otherwise valid will still get a 401 if the IDs don't match.

In practice: mint per-request, and set sub from the URL you're about to call. Never reuse a JWT minted for user A on a URL pointing at user B.


6. Troubleshooting 401s

SymptomFirst thing to check
401 on every request, empty response bodykidiss, wrong aud, expired or skewed iat/nbf, wrong private key, or the public key registered against your kid has expired in vault
401 only on user routesMissing sub + subsig, or subUSERID
403 with {"code":"GEO_BLOCKED",…}client_ip missing, not parseable, or in a non-licensed jurisdiction. Confirm you're passing the end user's IP, not your server's.
Signature looks right but server rejects itBase64 padding included on digest or subsig. Strip the = signs.
Works in dev, intermittent 401s in prodServer clock drift. Make sure NTP is running.
Same JWT works twiceExpected — jti uniqueness isn't enforced server-side. Your generator should still produce unique values.

7. A practical suggestion

Prove out the wire format end-to-end against a known-good signer first — then port that into your backend. The Postman pre-request script below does exactly the signing flow described in this doc; drop it into a Postman collection's Pre-request Script tab, fill in the constants at the top, and every request in the collection gets a fresh signed JWT.

You'll need four values from your ProphetX contact (the first three; the fourth comes from POST /users), plus the end user's IP on user-scoped routes:

ConstantSource
issYour ISV UUID — DM'd by your ProphetX contact.
privateKeyThe base64-encoded PKCS8 form of your Ed25519 private key.
subA user UUID. Required only for user-scoped routes. For sandbox testing, KYC a "dummy" user to get one.
sharedSecretReturned exactly once by POST /private/v1/users for that user.
clientIpThe trader's IP address. For sandbox/Postman testing you can set this to your own public IP; in production capture it from the request hitting your backend.
//
// Postman pre-request script to generate private key JWT.
// Set Authorization -> Auth Type to 'No Auth' — the script inserts the Authorization header.
//

const iss = 'YOUR_ISV_ID'              // issuer = ISV UUID
const sub = 'YOUR_USER_ID'             // subject = user UUID, required for user-scoped requests
const privateKey = 'YOUR_PRIVATE_KEY'  // your private key (base64 PKCS8)
const sharedSecret = 'YOUR_SHARED_SECRET'  // user-scoped shared secret
const clientIp = 'YOUR_CLIENT_IP'      // the END USER's IP — required for user-scoped requests when geofencing is on

function base64url(data) {
    const buf = Buffer.from(JSON.stringify(data))
    return buf.toString('base64url')
}

async function hashBody() {
    const body = pm.request.body.raw
    // Empty hash for empty request body
    if (body == null) {
        return ''
    }
    // Base64 URL encoded SHA-256 hash with no padding
    const digest = await crypto.subtle.digest('SHA-256', Buffer.from(body))
    return Buffer.from(digest).toString('base64url')
}

async function signSubject(sub, iat, jti) {
    const data = `${sub}:${iat}:${jti}`
    const key = await crypto.subtle.importKey(
        'raw',
        Buffer.from(sharedSecret, 'base64url'),
        { name: 'HMAC', hash: 'SHA-256' },
        false,
        ['sign'],
    )
    // Base64 URL encoded HMAC-SHA-256 signature with no padding
    const signature = await crypto.subtle.sign({ name: 'HMAC' }, key, Buffer.from(data))
    return Buffer.from(signature).toString('base64url')
}

const header = base64url({
    typ: 'JWT',
    alg: 'EdDSA',
    kid: iss,  // key ID = ISV UUID (KrakenD requires this)
})

const now = Math.floor(Date.now() / 1000)
const jti = `req-${now}`
const digest = await hashBody()
const subsig = await signSubject(sub, now, jti)

const claims = base64url({
    iss,              // issuer = ISV UUID
    sub,              // subject = user UUID
    aud: 'prophetx',  // audience
    exp: now + 60,    // expiration time
    nbf: now - 10,    // not before
    iat: now,         // issued at
    jti,              // JWT ID
    digest,           // SHA-256 hash of HTTP body
    subsig,           // HMAC-SHA-256 signature of "sub:iat:jti"
    client_ip: clientIp,  // end user's IP — used by the gateway's geo gate on user-scoped routes
})

// Sign and build JWT
const headerClaims = `${header}.${claims}`
const key = await crypto.subtle.importKey(
    'pkcs8',
    Buffer.from(privateKey, 'base64'),
    { name: 'Ed25519' },
    false,
    ['sign'],
)
const signature = await crypto.subtle.sign({ name: 'Ed25519' }, key, Buffer.from(headerClaims))
const token = `${headerClaims}.${Buffer.from(signature).toString('base64url')}`

pm.request.headers.add({ key: 'Authorization', value: `Bearer ${token}` })

A few notes for adapting it to your own runtime:

  • ISV-only routes (e.g. /tournaments, /sport-events, /markets, /push/*) don't need sub, subsig, or client_ip — either omit those claims or leave them in and rely on the server to ignore them for ISV-scoped paths.
  • User-scoped routes require sub (= the USERID in the URL), subsig, and client_ip (the trader's IP, not your server's). Don't reuse a JWT minted for user A on a URL pointing at user B.
  • exp is capped at < 300 seconds; this script uses 60.
  • crypto.subtle.sign for Ed25519 requires a recent Postman runtime — if you're on an older Postman version that doesn't support it, ask your ProphetX contact for a node-jose-based equivalent.