docs/mobile-api-keys.md — what the server accepts, what changes in monorepo-mobile, and the 401-vs-403 distinction, which is the one that bites: clearing a good key on a 403 turns a member's missing capability into a logout loop they cannot escape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 KiB
API keys — implementing the client side
Written 2026-08-08, for the mobile developer. Server side is live and verified; nothing below is planned-but-missing unless it says so.
The mobile apps are not built here. This document is the boundary: what the server now accepts, what
changes in monorepo-mobile, and what does not.
The short version
An API key goes exactly where the JWT goes. Same Authorization: Bearer … header, same ?token=
query fallback for media, same ?token= on the WebSocket. Every door was taught the new format at once,
so there is no endpoint where a key works and another where it doesn't.
That is the whole point of the design, and it means the client change is small:
packages/core/src/services/api.tsneeds no change at all — it already sends whatevergetToken()returns as a bearer token.packages/core/src/state/useAuth.tsgains a second way to obtain that value.- Multi-server storage already exists (
tokenKeyFor(activeServerId())inservices/servers.ts), so a key per server slots into the same SecureStore entry the JWT uses today.
The problem being solved is multiple logins across the apps. Today every app signs in with the password and gets its own 30-day JWT, and there is no way to cut one device off without a password change that kills all of them. A key is revocable on its own, from the web UI, in one click.
What a key looks like
ofk_eRgp_Fgqr5hKAyydvxfvu-HL12s-HYwSlTS7wYg-Ua0
ofk_ prefix, then 32 CSPRNG bytes base64url-encoded. Treat it as opaque. Do not parse it, do not
validate its length, do not assume 47 characters — the only guarantee is the ofk_ prefix and that it is
URL-safe and header-safe.
- It does not expire unless one was asked for at creation. Assume no expiry.
- It is shown exactly once, by the response that creates it. The server stores a SHA-256; there is no endpoint that reads a key back and there never will be. If it is lost, revoke and mint another.
- It carries the account's full authority — the same as the password, no more. A member's key is still a member.
Two ways for the app to get one
(a) Mint it in-app — recommended
Sign in with the password once, immediately trade the JWT for a key, store the key, throw the JWT away. No copy-paste, no typing 47 characters on a phone keyboard.
POST /api/auth/signin {email, password} → {token, user}
POST /api/api-keys {name: "iPhone 15"} → {entry, key} (Authorization: Bearer <token>)
store `key`, discard `token`
Name it after the device, not the app — the owner reads that string in the web UI when deciding what to revoke, and "iPhone 15" is a decision they can make while "music" is not.
If you want one key per app rather than per device, name it "<device> — <app>". Either is fine; be
consistent so the list stays readable.
(b) Paste a key the owner made in the web UI
Settings → Integrations → Personal → API keys. Useful for a device that cannot show a sign-in form, and as the recovery path when (a) fails. A paste field that accepts the key and skips signin entirely.
Validate only that it is non-empty and starts with ofk_, then make a real request and let the server
answer.
Using it
Identical to the JWT in all three places:
| Where | How |
|---|---|
| Normal requests | Authorization: Bearer ofk_… |
Media URLs (<Image>, <Video>, file /raw) |
?token=ofk_… — URL-encode it |
| WebSockets | ?token=ofk_… on the upgrade URL |
The ?token= fallback is accepted on every protected /api route, not just media. That is
pre-existing behaviour and not something to rely on: it puts the credential in URLs, which reach proxy
logs. Use the header wherever a header is possible.
What changes in the client
1. useAuth: a key is a session. The current signin throws when the response has no token
('Sign-in did not return a token'). A key-based login never calls /api/auth/signin at all — it calls
GET /api/auth/me with the key to confirm it works and to learn who it belongs to, then persists it and
sets the signed-in state.
2. Do not call POST /api/auth/signout when signed in with a key. It is harmless — the server skips
the blacklist step for a key and returns {ok: true} — but it does nothing useful either. "Sign out"
with a key means: clear it locally. If the user wants it dead server-side, that is revoke, and it
belongs in the app's settings screen, not on the sign-out button. Wording matters here: signing out of
one phone should not silently disable a key another app is using.
3. Store it exactly where the token goes. tokenKeyFor(activeServerId()) in SecureStore. Nothing
else needs to know which kind of credential it holds — that is what makes this change small.
4. Optionally: let the app revoke its own key. DELETE /api/api-keys/:id works when authenticated
with that same key. Keep entry.id from the create response if you want a "disconnect this device"
button.
Error semantics — the part worth getting right
Error bodies are plain text, not JSON. Unauthorized, Forbidden, Not Found, Invalid request body. There is no {error} or {message} envelope anywhere. packages/core/src/services/api.ts
already handles this correctly (it tries JSON and falls back to raw text) — do not "fix" it.
The one exception: 429 returns {"retryAfter": <seconds>} as JSON, with no Retry-After
header. Rate limiting applies to /api/auth/* only, so a key-based client that never calls signin
will not meet it.
401 and 403 mean different things and must be handled differently.
| Status | Meaning | What the app should do |
|---|---|---|
| 401 | The credential is dead — revoked, expired, or never valid. | Clear it, send the user to the login screen. |
| 403 | The credential is fine; this account may not reach this feature. | Do not clear the credential. Show "not available for your account" and stay signed in. |
Clearing a good key on a 403 is the failure mode to avoid: it turns a member's missing capability into a logout loop they cannot escape, because signing in again produces a credential with the same 403.
A revoked key goes 401 on the very next request — revocation is checked in SQL at lookup, not cached.
What a key can and cannot reach
Authorization is unchanged by how you authenticated. A key resolves to a user, and that user's role decides everything after.
- The owner (user 1) reaches everything.
- Any other account reaches only what its role has been granted, and can never reach the
executioncapabilities — terminal, chat, tasks, files, desktop, browser. Those run as the owner's OS user in the owner's home; they are refused structurally, not by policy.
Verified: a member's key returns the same status as that member's JWT on every route tried, 403s included. If you see a key behave differently from a password login for the same account, that is a bug — report it, don't work around it.
For sockets specifically: cliamp and cliamp-audio (music playback) are grantable. chat,
terminal, task-runner, pipeline and desktop are owner-only. useChatSocket therefore works for
the owner and will always 403 for a member — that is not new, and not caused by keys.
Endpoint reference
All three require an authenticated caller and act only on that caller's own keys. Nothing accepts a user id; there is no request shape that reaches another account's keys.
POST /api/api-keys
// request
{ "name": "iPhone 15", "expiresInDays": 90 } // expiresInDays optional; omit for no expiry
// 200
{
"entry": {
"id": 1, "userId": 1, "name": "iPhone 15",
"prefix": "ofk_eRgp_F", // display only — first 10 chars, never enough to use
"lastUsedAt": null, "expiresAt": null, "revokedAt": null,
"createdAt": "2026-08-08T10:08:58.687Z"
},
"key": "ofk_eRgp_Fgqr5hKAyydvxfvu-HL12s-HYwSlTS7wYg-Ua0" // the only time this appears
}
400 on a missing/blank name, a name over 100 characters, or a non-positive expiresInDays.
GET /api/api-keys
{ "keys": [ /* entry objects as above, newest first — never the key itself */ ] }
lastUsedAt is debounced to at most one write a minute, so it can lag by up to 60 seconds. It is a
"which of these is still in use" signal, not an audit trail.
DELETE /api/api-keys/:id
{"ok": true}, or 404 if the id is not yours or is already revoked — deliberately the same answer for
both, so the endpoint cannot be used to discover whether an id exists.
Things that will surprise you
- No
Originheader is needed today.ALLOW_ANY_ORIGINdefaults to on, so origin checking is off and the apps work sending none — which is what they do. Nothing here changes that. If it is ever switched off, every app breaks at once and will need itsOFFICER_<APP>_ORIGINvalue compiled in; that is a separate conversation, not part of this work. /api/auth/signincan return 200 with no token. Pre-existing: it happens when the account has a passkey registered against the caller's origin, and it means "now do WebAuthn". Mobile sends noOrigin, so it never triggers today. Minting a key at first sign-in and never signing in again makes the app immune to it permanently — a real reason to prefer path (a).userin the signin response has norole. It is{id, email, name, username, passkeys}wherepasskeysis a count. Role comes fromGET /api/auth/me. The mobileAuthUsertype currently declaresroleas required, which is wrong for signin — worth fixing while you are in there.- A key can mint another key. There is no parent/child link, so revoking a key does not revoke ones created with it. This is a consequence of a key carrying full account authority and is accepted for now; it is the strongest single argument for scoped keys.
Not built
- Scopes. A key cannot be narrowed to a subset of its holder's capabilities. The column and the check
are a small change (
resolveApiKeyinsrc/servers/auth-token.tsis the one place), but nothing is there today. Design as if every key is full-authority, because it is. - A key-management screen in the mobile apps. Only the web UI can list and revoke. Fine to leave — revocation from a phone that has been lost is not a thing you can do from the phone.
- Server-side "sign out everywhere". Password change invalidates JWTs but deliberately not API keys, since rotating them independently is the reason they exist. If the owner wants everything dead, they revoke each key.
Verifying against a real server
BASE=https://officer.pastilhas.dev
TOKEN=$(curl -s -X POST $BASE/api/auth/signin -H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"…"}' | jq -r .token)
KEY=$(curl -s -X POST $BASE/api/api-keys -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"name":"curl test"}' | jq -r .key)
curl -s -o /dev/null -w '%{http_code}\n' $BASE/api/auth/me -H "Authorization: Bearer $KEY" # 200
ID=$(curl -s $BASE/api/api-keys -H "Authorization: Bearer $KEY" | jq -r '.keys[0].id')
curl -s -X DELETE $BASE/api/api-keys/$ID -H "Authorization: Bearer $KEY" # {"ok":true}
curl -s -o /dev/null -w '%{http_code}\n' $BASE/api/auth/me -H "Authorization: Bearer $KEY" # 401
That sequence — create, use, revoke, 401 — is the one that was run against the live server before this
document was written, along with a WebSocket upgrade on /api/cliamp/ws?token=<key> returning 101.
Where this lives on the server
src/servers/auth-token.ts— the key format andresolveAuthToken, the single function that turns a bearer string into a caller. All four doors call it:userMiddleware,originScopeMiddleware, the WebSocket upgrade inserver.tsx, and the vault socket.src/servers/api/api-keys/router.ts— the three endpoints.src/databases/officer_db/src/schema/api-keys.ts— the table, and why it stores what it stores.