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
kidin your JWT header and theissin your claims. - Your
fundType:SHAREDorINDIVIDUAL. 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:
| Claim | When | Value |
|---|---|---|
iss | always | Your ISV UUID. Must equal kid. |
aud | always | The literal string "prophetx". |
iat | always | Now. Within ±30s of the server's clock. |
nbf | always | Now. Same window. |
exp | always | iat + N where N < 300. Five-minute hard ceiling. |
jti | always | A unique identifier for this request. Server-side uniqueness is not enforced — make sure your generator produces fresh values. |
digest | when body is non-empty | Base64URL(SHA256(body)), no padding. May be omitted or empty-string on GET requests. |
sub | user-scoped routes | The user's UUID. Must equal USERID in the URL exactly. |
subsig | user-scoped routes | Base64URL(HMAC-SHA256(secret, "<sub>:<iat>:<jti>")), no padding. secret is the user's shared secret. |
Sign the JWT with your Ed25519 private key and send it as Authorization: Bearer <jwt>.
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 sub ↔ USERID rule
sub ↔ USERID ruleIf 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
| Symptom | First thing to check |
|---|---|
| 401 on every request | kid ≠ iss, wrong aud, expired or skewed iat/nbf, or wrong private key |
| 401 only on user routes | Missing sub + subsig, or sub ≠ USERID |
| Signature looks right but server rejects it | Base64 padding included on digest or subsig. Strip the = signs. |
| Works in dev, intermittent 401s in prod | Server clock drift. Make sure NTP is running. |
| Same JWT works twice | Expected — jti uniqueness isn't enforced server-side. Your generator should still produce unique values. |
7. Path scopes recap
| Prefix | Caller | Token |
|---|---|---|
/private/v1/* | Your backend | You mint per-request (this doc) |
/embed/v1/* | Embedded UI in the user's browser | You mint via GET /private/v1/tokens and hand to the browser. See Embedded UI (tokens + /embed/v1). |
8. A practical suggestion
If you can, ask ProphetX for a reference JWT-generator script for Bruno or Postman and prove out the wire format end-to-end with that first. Once you've confirmed the server is happy with a known-good signer, porting the logic into your own backend is much easier than trying to write a Go/Java/Python implementation against the spec cold.
Updated 3 days ago
