Authentication
Keys, environments, scopes and rotation.
Every request under /v1 needs an API key or, from the fxapis console,
a signed-in member's session. /healthz and /readyz need neither.
Authorization: Bearer fx_live_7f3a9c21_ZmQ4YmE2MjNhOTg3ZmUxMGJjNGQ...The bare key without Bearer is also accepted, because people paste both.
The shape of a key
fx_live_7f3a9c21_ZmQ4YmE2MjNhOTg3ZmUxMGJjNGQ...
── ──── ──────── ──────────────────────────────
│ │ │ └ secret — 32 random bytes, base64url
│ │ └ public id — how we find the row; appears in logs
│ └ environment — live or test
└ vendor prefix — makes keys greppable in your codebaseTwo parts of that are deliberate and worth knowing about.
The environment is in the key. A test key presented to a live account
fails at authentication, not at the order. Configuration mix-ups are the normal
way a test order reaches a real account, and this makes that mix-up loud.
The prefix is fx_. Secret scanners can find it, and so can you: grep -r fx_live_ . before a commit is a habit worth having. If you push one to a public
repository, assume it is compromised and rotate it. Keys issued before the
fxapis name start ow_; they keep working exactly as issued, and their hint
shows ow_, so grep for both.
We cannot recover a key for you
A key is shown once, at creation. We store only a SHA-256 digest, so a dump of our key table cannot be replayed against the API — and neither can we read your key back. Lost keys are replaced, not recovered.
Scopes
Scopes are per key. Issue narrow keys.
| Scope | Allows |
|---|---|
accounts:read | List and read accounts, poll status |
accounts:write | Connect, warm, cool, restart, disconnect, change mode |
trading:read | Read positions and orders |
trading:reduce | Close positions, move their stops, cancel pending orders and waves — nothing that opens exposure |
trading:execute | Place orders and everything trading:reduce allows |
history:read | Read closed trades and account history |
webhooks:write | Manage webhook endpoints |
keys:manage | Create, rename, re-scope and revoke this workspace's keys. Not grantable to a key |
members:manage | Invite, re-role and remove members. Only ever held by a signed-in owner or admin |
admin | Everything, including key management. Not grantable to a key |
A key holding admin passes every scope check. Do not use one for a running
integration; use it to mint the narrow keys the integration actually needs.
Scope is not ownership. Holding accounts:write lets you write accounts your
tenant owns — another tenant's account is not forbidden, it is not found.
Returning 403 there would confirm that a guessed id exists.
Why we do not use a slow hash
API keys are digested with SHA-256 rather than argon2 or bcrypt. That is a deliberate inversion of normal password advice, and the reasoning is specific.
A password is low-entropy and needs a slow hash to survive a dictionary attack. A 256-bit random secret has no dictionary; brute force is not on the table at any speed. What a key does need is verification on every request without adding milliseconds — which is exactly what a password hash is designed to prevent.
We also verify against a dummy digest when the public id is unknown, so a request for a nonexistent key costs the same as one for a real key with a wrong secret. Returning early on "no such key" is the classic enumeration timing leak.
Managing keys
From the console, or with /v1/api-keys:
- Create returns the key in
key, once. It is not stored, so it cannot be shown again; the response carriesCache-Control: no-store. - List and read show a hint (
fx_live_7f3a9c21_…), the scopes, and when the key was last used, to the minute — never the key. - Rename and re-scope keep the key itself, so nothing using it redeploys. New scopes apply from its next request.
- Revoke is refused from the next request, is safe to repeat, and cannot be undone. A key may revoke itself.
admin and keys:manage cannot be put on a key this way. A key that could
mint keys could mint itself a stronger one. A workspace holds at most 50
active keys.
Rotation
Keys are independent, so rotation needs no downtime:
- Create the new key.
- Deploy it.
- Confirm traffic on the new key — the audit log records
apiKeyIdper request. - Revoke the old one.
Revocation takes effect on the next request. There is no cache to wait for.
Failures
Every authentication failure returns the same body:
{
"error": { "code": "UNAUTHENTICATED", "message": "A valid API key is required." },
"requestId": "3f8a..."
}Malformed, unknown, wrong secret, revoked, expired — one response for all of them. Telling you which would let someone with a list of candidate keys sort the real ones from the fake. Our logs record the distinction; the response does not.
A key that authenticates but lacks the scope gets MISSING_SCOPE (403) instead,
which does name the missing scope — by then you have proven you hold a valid
key, and a useful message costs nothing.
Member sessions
The console does not use an API key, and never puts one in the browser. A
member signs in with an email and password at /auth, and the
session cookie that comes back is accepted under /v1 in place of a key.
It resolves to the same thing a key does: a tenant and a set of scopes. So a
member is held by exactly the same checks, and another tenant's account is
404 to them just as it is to a key. The scopes come from their role in the
tenant rather than from a list on a key:
| Role | Scopes |
|---|---|
owner, admin | the read scopes, accounts:write, trading:reduce, webhooks:write, keys:manage |
member | accounts:read, trading:read, history:read |
The console cannot open a trade. It can close positions, move stops and cancel what has not happened yet; opening exposure is done with a key, by your own code.
No role carries admin. That scope passes every check and is not grantable
through the public API; a browser session is the last place to put one.
A few things differ from a key, deliberately:
- A key always wins. A request with an
Authorizationheader is judged as a key alone. A bad key does not fall back to a cookie sent beside it, and a narrow key does not borrow the scopes of an owner's cookie. - Cross-site requests are refused. A browser attaches a cookie on its own,
which nobody does with a key. A state-changing request with a session whose
Originis not the console gets403 UNTRUSTED_ORIGIN. - Signing out takes effect on the next request. There is no cached session to wait out, for the same reason revoking a key has none.
- Console traffic is not metered as API calls, and is rate limited in buckets of its own, so looking at your usage neither costs usage nor spends the budget your integration needed.
Anyone may sign up with an email and a password, and becomes the owner of a new workspace on the free plan. GitHub and Google sign-in are not offered yet.
Members
A workspace can have several people in it, all seeing the same accounts, keys and usage:
| Owner | Admin | Member | |
|---|---|---|---|
| Read accounts, orders, usage | ✓ | ✓ | ✓ |
| Close, move stops, cancel | ✓ | ✓ | |
| Manage keys | ✓ | ✓ | |
| Invite | admins and members | members | |
| Change roles | ✓ | ||
| Remove | admins and members | members |
Nobody changes or removes the owner, and nobody acts on themselves. People are
managed by people: every /v1/members route refuses API keys, admin keys
included.
Invitations return a link to send; there is no email yet. Inviting an address that already has an open invitation returns that one — the same link — rather than a second. A link works once, expires after seven days, and makes its holder a member of that workspace with that role; nothing they send can change either. The link is a signature over the invitation's id, so it is not stored anywhere it could be stolen from.
Removing a member signs them out everywhere on their next request and deletes their password. What they did stays attributed to them. Keys they created belong to the workspace and keep working until revoked.
One person belongs to one workspace: an address that already has an account cannot be invited to another.