CLI auth protocol
This document specifies the contract the Promptless CLI expects from the
web app and backend so promptless login works. The CLI side is in
src/commands/login.ts.
- The user signs in through Clerk in the web app (no Clerk creds on the device — the CLI never sees a Clerk token).
- A long-lived API key is created on the user’s behalf and delivered to the CLI without ever appearing in plaintext in browser history, the URL bar, or shell history.
- The CLI never has to trust the browser address bar with anything sensitive. The encryption key never leaves the CLI process — only the public half does.
Shape of the flow
Section titled “Shape of the flow”┌──────┐ 1. open URL ┌────────┐ 2. POST /v1/cli/api-keys ┌─────────┐│ CLI │ ────────────▶ │ Web │ ────────────────────────▶ │ Backend │└──────┘ (browser) │ app │ └─────────┘ │ ▲ │ (Clerk │ 3. { encrypted_key } ▲ │ │ │ -auth) │ ◀────────────────────────────────┘ │ │ └────────┘ │ │ │ │ │ 4. fetch(http://127.0.0.1:PORT/auth/callback, POST { encrypted_key, state, key_type }) │ ▼ │ └─────────────────────── ┘ (CLI's local HTTP server)There is no HTTP 302 redirect in this flow. The web app delivers the
encrypted key via a JavaScript fetch() to the CLI’s loopback server,
not via a navigation redirect. This keeps the ciphertext out of browser
history entirely.
Pieces
Section titled “Pieces”1. api_keys table
Section titled “1. api_keys table”The backend needs an api_keys table. Suggested shape:
| column | type | notes |
|---|---|---|
id | uuid PK | |
user_id | fk users | |
organization_id | fk orgs? | nullable — depends on org model |
name | text | human label, e.g. "cli on isaiah-laptop" |
prefix | text | first ~8 chars of the key, for display |
key_hash | text | argon2id / scrypt / bcrypt of the full key |
key_type_at_issue | text | the key_type from the issuing request |
created_at | timestamptz | |
last_used_at | timestamptz | |
revoked_at | timestamptz | null = active |
The full key is shown to the user (via the CLI) exactly once.
2. Web app page: app.gopromptless.ai/cli/auth
Section titled “2. Web app page: app.gopromptless.ai/cli/auth”Receives these query params from the CLI:
public_key— base64url of the CLI’s SPKI-DER-encoded RSA public key.key_type— the protocol version. Currentlyv1(see #4 below).redirect_uri—http://127.0.0.1:<port>/auth/callback. Must be validated to only allowhttp://127.0.0.1:*orhttp://localhost:*hosts with the literal path/auth/callback.state— base64url, opaque, echoed back unchanged.
Flow:
-
If the user is not signed in to Clerk, send them through the normal sign-in flow, then return to this page with the same query string.
-
Render an “Authorize the Promptless CLI on this device?” confirmation. (Optional, but recommended — without it, a malicious link is enough to authorize a key.)
-
POST to the backend (see #3) with
{ public_key, key_type, device_label }. -
POST the response body to
redirect_uri:await fetch(redirect_uri, {method: 'POST',mode: 'cors',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({encrypted_key, // from backend responsestate, // echoed from query stringkey_type, // echoed from query string}),})The CLI’s local server is bound to
127.0.0.1and responds204on success. Browsers automatically set theOriginheader on this cross-origin POST; the CLI uses it to enforce that only its configured app origin can deliver the callback. -
On user cancel / error: POST
{ error, error_description, state }to the same endpoint, or show the error inline without ever calling the callback. The CLI will time out after 5 minutes.
3. Backend endpoint: POST /v1/cli/api-keys
Section titled “3. Backend endpoint: POST /v1/cli/api-keys”-
Auth: Clerk session cookie (called from web app, same origin).
-
Body:
{"public_key": "<base64url SPKI DER>","key_type": "v1","device_label": "..."} -
Action:
-
Identify the calling Clerk user; derive their active
org_idfrom the Clerk session (whichever organization they have currently selected in the UI). -
Validate
key_typeis supported. Unknown types →400. -
Generate a new API key (e.g.
plk_+ 32 random base64url chars). -
Insert a row in
api_keysfor(user_id, organization_id)storing only the hash, prefix, andkey_type_at_issue. -
Encrypt the plaintext key with the supplied
public_keyper the algorithm in #4. -
Return:
{ "encrypted_key": "<base64url>", "key_type": "v1" }
-
The backend echoes key_type back so the CLI can sanity-check that the
backend used the scheme it asked for. If the backend ever supports
multiple types simultaneously, this also tells the CLI which decryption
path to use.
4. key_type=v1 encryption format
Section titled “4. key_type=v1 encryption format”The CLI decrypts with this exact format — see decryptApiKey in
src/commands/login.ts.
- Algorithm: RSA-OAEP with SHA-256 as both the hash and MGF1 hash.
- Key: 2048-bit RSA. The wire format for the public key is
base64url(SPKI DER). - Plaintext: the API key as UTF-8 bytes.
- Output:
base64url(ciphertext). The ciphertext is exactlymodulusLength / 8bytes (256 for RSA-2048).
Node reference implementation (backend):
import { createPublicKey, publicEncrypt, constants } from 'node:crypto'
function encryptApiKey(apiKey: string, publicKeyB64u: string): string { const der = Buffer.from( publicKeyB64u.replace(/-/g, '+').replace(/_/g, '/'), 'base64', ) const publicKey = createPublicKey({ key: der, format: 'der', type: 'spki' }) const ct = publicEncrypt( { key: publicKey, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256', }, Buffer.from(apiKey, 'utf8'), ) return ct.toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '')}When a v2 is ever introduced, give it a different key_type string
and a separate algorithm spec; do not silently change the format of
v1.
5. Backend endpoint: GET /v1/me
Section titled “5. Backend endpoint: GET /v1/me”-
Auth:
Authorization: Bearer <api_key>. -
Action: look up
key_hashinapi_keys(skip rows whererevoked_at IS NOT NULL), bumplast_used_at, return:{"user_id": "user_xxx","email": "isaiah@example.com","name": "Isaiah","organizations": [{ "id": "org_xxx", "name": "Promptless" }]} -
401if the key is unknown or revoked.403if the user is disabled.
The CLI’s local server enforces:
Originheader must equal the CLI’s configured app origin (defaults tohttps://app.gopromptless.ai; overridable viaPROMPTLESS_APP_BASE_URL). Anything else gets403.OPTIONS /auth/callbackpreflight is handled — responds204withAccess-Control-Allow-Methods: POST, OPTIONSandAccess-Control-Allow-Headers: Content-Type.- The actual
POSTresponse includesAccess-Control-Allow-Origin: <configured origin>.
The browser sets Origin automatically for cross-origin fetch calls,
so a real web app on app.gopromptless.ai works out of the box. For
test fixtures running on Node, use http.request (not fetch / undici)
— the Fetch spec forbids setting the Origin header from client code,
and undici silently drops it.
Security notes
Section titled “Security notes”- The
redirect_urimust only allow loopback addresses (127.0.0.1orlocalhost). Otherwise a malicious site could trick a signed-in user into delivering a fresh API key to an attacker-controlled URL. - The private key never leaves the CLI process and is held only in memory. Even if the auth URL is screenshotted or shared, the public key it contains is useless on its own.
- The CLI binds the callback server to
127.0.0.1only (not0.0.0.0). - The encrypted key is delivered via a JSON POST, so it never appears
in the browser URL bar, history, or HTTP
Refererheaders. - The web app should validate the
redirect_uriserver-side as well as in the browser — don’t trust a query parameter on a sensitive page.