Skip to content

For the complete documentation index, see llms.txt.

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.
┌──────┐ 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.

The backend needs an api_keys table. Suggested shape:

columntypenotes
iduuid PK
user_idfk users
organization_idfk orgs?nullable — depends on org model
nametexthuman label, e.g. "cli on isaiah-laptop"
prefixtextfirst ~8 chars of the key, for display
key_hashtextargon2id / scrypt / bcrypt of the full key
key_type_at_issuetextthe key_type from the issuing request
created_attimestamptz
last_used_attimestamptz
revoked_attimestamptznull = 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. Currently v1 (see #4 below).
  • redirect_urihttp://127.0.0.1:<port>/auth/callback. Must be validated to only allow http://127.0.0.1:* or http://localhost:* hosts with the literal path /auth/callback.
  • state — base64url, opaque, echoed back unchanged.

Flow:

  1. 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.

  2. Render an “Authorize the Promptless CLI on this device?” confirmation. (Optional, but recommended — without it, a malicious link is enough to authorize a key.)

  3. POST to the backend (see #3) with { public_key, key_type, device_label }.

  4. 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 response
    state, // echoed from query string
    key_type, // echoed from query string
    }),
    })

    The CLI’s local server is bound to 127.0.0.1 and responds 204 on success. Browsers automatically set the Origin header on this cross-origin POST; the CLI uses it to enforce that only its configured app origin can deliver the callback.

  5. 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:

    1. Identify the calling Clerk user; derive their active org_id from the Clerk session (whichever organization they have currently selected in the UI).

    2. Validate key_type is supported. Unknown types → 400.

    3. Generate a new API key (e.g. plk_ + 32 random base64url chars).

    4. Insert a row in api_keys for (user_id, organization_id) storing only the hash, prefix, and key_type_at_issue.

    5. Encrypt the plaintext key with the supplied public_key per the algorithm in #4.

    6. 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.

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 exactly modulusLength / 8 bytes (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.

  • Auth: Authorization: Bearer <api_key>.

  • Action: look up key_hash in api_keys (skip rows where revoked_at IS NOT NULL), bump last_used_at, return:

    {
    "user_id": "user_xxx",
    "email": "isaiah@example.com",
    "name": "Isaiah",
    "organizations": [{ "id": "org_xxx", "name": "Promptless" }]
    }
  • 401 if the key is unknown or revoked. 403 if the user is disabled.

The CLI’s local server enforces:

  • Origin header must equal the CLI’s configured app origin (defaults to https://app.gopromptless.ai; overridable via PROMPTLESS_APP_BASE_URL). Anything else gets 403.
  • OPTIONS /auth/callback preflight is handled — responds 204 with Access-Control-Allow-Methods: POST, OPTIONS and Access-Control-Allow-Headers: Content-Type.
  • The actual POST response includes Access-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.

  • The redirect_uri must only allow loopback addresses (127.0.0.1 or localhost). 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.1 only (not 0.0.0.0).
  • The encrypted key is delivered via a JSON POST, so it never appears in the browser URL bar, history, or HTTP Referer headers.
  • The web app should validate the redirect_uri server-side as well as in the browser — don’t trust a query parameter on a sensitive page.