How-to › Ship a CLI or REPL

How to wire OpenAPI security schemes into CLI auth flags#

Turn each OpenAPI security scheme into a CLI flag backed by an environment variable, place the value where the scheme says, and prove it with a 401 and a 200.

Audience
API producer
Level
intermediate
Topic
Build a CLI over an API
Verified

Read first: Map API parameters to CLI arguments and flags

Your CLI has a command for every operation and no way to log in. The OpenAPI document declares four security schemes, and each operation accepts a different one. The first user ran ledger entries list, got a 401, and pasted a token into a positional argument, where ps can read it.

What you get

You will end up with flags and environment variables derived from the security schemes, placed where each scheme says, and a test that proves them with a 401 and a 200. This is for you if your command tree exists and auth is still improvised.

Short answer

Read components.securitySchemes and each operation’s security list. Give every scheme type one input. An apiKey scheme becomes --api-key, placed in the header or query it names, and http bearer becomes --token. http basic becomes --user, and OAuth client credentials become --client-id and --client-secret with a token fetched per call. Back each flag with an environment variable, list both in --help, and prove the wiring with a request that gets 401 without the input and 200 with it.

You will need

Node 22 or later, an OpenAPI 3 document with securitySchemes, and the command and parameter maps from mapping API parameters to CLI arguments and flags. Verified 2026-10-21 against Node 22.22.0. The sample is a five-operation ledger API with four scheme types, a node:http server that enforces them on port 0, and a CLI that reads the document. Nothing leaves the machine.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Hand-written auth flagsYou ship the CLI and want its flags, help, and errors in your own wordsEvery scheme type coded by hand, and a mapping kept in step with the document as schemes are addedThe CLI is generated at run time from the document, where restish does this for you
HTTPie -A auth typesOne-off calls from a shell, with basic, digest, or bearer, and plugins for the restOne -a value per call, typed again or kept in a session file in plain text, with no reading of the documentYou ship a CLI to other people, or the API wants client credentials
restish auth typesAny API with an OpenAPI document, and seven auth types configured once per profileA profile per API in the restish config file, and an auth model that belongs to restish rather than to youYou need one binary with your own command names and no dependence on the document at run time

HTTPie answers the question per call: -A bearer -a TOKEN puts a token on one request, and a session file keeps it, in plain text, for the next. restish answers it per API: api connect reads the security schemes and prompts for values, and api inspect prints the saved profile with the secrets redacted. Writing the flags yourself costs the mapping below and buys a binary whose help, errors, and names are your own.

Turn each scheme into one input

The Security Scheme Object carries a type, and the type decides the input. An apiKey scheme names a header, query, or cookie parameter. An http scheme names an Authorization scheme, bearer or basic. An oauth2 scheme carries flows, and the client-credentials flow carries a tokenUrl. The Security Requirement Object says which schemes an operation accepts. Each entry in the list is an alternative, and every scheme inside one entry is required together. An operation’s own list replaces the document’s, and an empty list means no credential at all.

export function inputsFor(scheme) {
  const env = (name) => `${ENV_PREFIX}_${name}`
  if (scheme.type === 'apiKey') {
    return { flags: ['--api-key'], env: [env('API_KEY')], placement: `${scheme.in} ${scheme.name}` }
  }
  if (scheme.type === 'http' && scheme.scheme === 'bearer') {
    return { flags: ['--token'], env: [env('TOKEN')], placement: 'header Authorization: Bearer <token>' }
  }
  if (scheme.type === 'http' && scheme.scheme === 'basic') {
    return { flags: ['--user'], env: [env('USER')], placement: 'header Authorization: Basic <user:password>' }
  }
  if (scheme.type === 'oauth2' && scheme.flows?.clientCredentials) {
    return {
      flags: ['--client-id', '--client-secret'],
      env: [env('CLIENT_ID'), env('CLIENT_SECRET')],
      placement: `POST ${scheme.flows.clientCredentials.tokenUrl}, then header Authorization: Bearer <token>`,
    }
  }
  return null
}

The two apiKey schemes share one flag, because the user holds one key and the scheme decides where it travels. A scheme the CLI cannot complete, an authorization-code flow that needs a browser or an openIdConnect discovery document, returns null, and the CLI says so rather than guessing. node auth.mjs prints the mapping and the alternatives per operation.

node auth.mjs
security schemes and the inputs they become
  ApiKeyHeader       apiKey                    --api-key                   LEDGER_API_KEY
                                               sent as header X-Api-Key
  ApiKeyQuery        apiKey                    --api-key                   LEDGER_API_KEY
                                               sent as query api_key
  BearerToken        http bearer               --token                     LEDGER_TOKEN
                                               sent as header Authorization: Bearer <token>
  BasicAuth          http basic                --user                      LEDGER_USER
                                               sent as header Authorization: Basic <user:password>
  ClientCredentials  oauth2 clientCredentials  --client-id --client-secret LEDGER_CLIENT_ID LEDGER_CLIENT_SECRET
                                               sent as POST /oauth/token, then header Authorization: Bearer <token>

operations and the alternatives they accept
  listEntries  GET   /v1/entries        BearerToken  or  ApiKeyHeader
  createEntry  POST  /v1/entries        ClientCredentials [ledger:write]
  getReport    GET   /v1/reports/{id}   BasicAuth
  getExport    GET   /v1/exports/{id}   ApiKeyQuery
  getHealth    GET   /v1/health         none

listEntries inherits the document’s list, so either a token or a key will do. createEntry replaces it with client credentials and a scope. getHealth sets security: [] and takes nothing.

Place each value where its scheme says

A flag is a value with a name. The scheme says where the value goes on the wire, and the standards say how. A bearer token goes in Authorization: Bearer, which RFC 6750 makes the method every resource server must support.1 Basic credentials go in Authorization: Basic as the base64 of user:password, per RFC 7617. Client credentials are posted to the tokenUrl with grant_type=client_credentials, as RFC 6749 sets out, and the token that comes back goes in the bearer header. The sample fetches it on every call; caching it is a different page’s job.

export async function apply(alt, creds, { headers, query, baseUrl, fetchImpl = fetch }) {
  for (const { name, scheme, scopes } of alt) {
    if (scheme.type === 'apiKey' && scheme.in === 'header') headers.set(scheme.name, creds['--api-key'])
    else if (scheme.type === 'apiKey' && scheme.in === 'query') query.set(scheme.name, creds['--api-key'])
    else if (scheme.type === 'apiKey' && scheme.in === 'cookie') headers.append('cookie', `${scheme.name}=${creds['--api-key']}`)
    else if (scheme.type === 'http' && scheme.scheme === 'bearer') headers.set('authorization', `Bearer ${creds['--token']}`)
    else if (scheme.type === 'http' && scheme.scheme === 'basic') {
      headers.set('authorization', `Basic ${Buffer.from(creds['--user'], 'utf8').toString('base64')}`)
    } else if (scheme.type === 'oauth2') {
      const flow = scheme.flows.clientCredentials
      const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: creds['--client-id'], client_secret: creds['--client-secret'] })
      if (scopes.length) body.set('scope', scopes.join(' '))
      const res = await fetchImpl(new URL(flow.tokenUrl, baseUrl), { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body })
      if (!res.ok) throw new Error(`${name}: token endpoint answered ${res.status}`)
      const token = await res.json()
      headers.set('authorization', `${token.token_type ?? 'Bearer'} ${token.access_token}`)
    } else {
      throw new Error(`${name}: scheme type ${scheme.type} is not implemented by this CLI`)
    }
  }
}

Each flag has an environment variable behind it, read when the flag is absent, and the help text names both. A flag is explicit and shows up in --help; an environment variable is invisible there unless you print it, and the help is where someone writing a script looks for it.

node cli.mjs --help
usage: ledger <command> [args] [credential flags]

commands and the credentials each accepts:
  entries list     --token (or LEDGER_TOKEN), or --api-key (or LEDGER_API_KEY)
  entries create   --client-id --client-secret (or LEDGER_CLIENT_ID, LEDGER_CLIENT_SECRET)
  reports get      --user (or LEDGER_USER)
  exports get      --api-key (or LEDGER_API_KEY)
  health           no credential required

credentials given as flags are visible in the process table; prefer the environment variables in scripts

Prove the wiring with a 401 and a 200

demo.mjs starts the server on port 0 and runs the CLI as a child process: once without a credential, then with one as a flag, then with one as an environment variable.

node demo.mjs
entries list: bearer token or API key, either one
  ledger entries list                                              401 {"error":"unauthorized"}  WWW-Authenticate: Bearer, X-Api-Key
  ledger entries list --api-key key_test_7f3a                      200 {"entries":[{"id":"e_1","amount":1200},{"id":"e_2","amount":-300}]}
  LEDGER_TOKEN=tok_test_91c2 ledger entries list                   200 {"entries":[{"id":"e_1","amount":1200},{"id":"e_2","amount":-300}]}
entries create: client credentials, token fetched per call
  ledger entries create                                            401 {"error":"unauthorized"}  WWW-Authenticate: Bearer scope="ledger:write"
  ledger entries create --client-id cid_test_01 --client-secret ... 201 {"id":"e_3"}
reports get: HTTP basic
  ledger reports get r_1                                           401 {"error":"unauthorized"}  WWW-Authenticate: Basic realm="reports"
  ledger reports get r_1 --user reports:s3cret                     200 {"id":"r_1","lines":14}
exports get: the same --api-key, sent in the query string
  ledger exports get x_9 --api-key key_test_7f3a                   200 {"id":"x_9","rows":2048}
health: security is an empty list
  ledger health                                                    200 {"status":"ok"}

Each bare call fails with 401 and a WWW-Authenticate challenge naming what the server wanted, and each call with the input succeeds. The same --api-key lands in a header for entries list and in the query string for exports get, because the two schemes differ in in and nothing else. The create call made two requests: one to /oauth/token, one to the API.

Check it worked

Sixteen tests cover the mapping, the placement, the redaction, and the server’s answers. The last four cover a command named constructor, a server URL that ends in /v1, an id of .., and a checkout path with a space in it.

node --test auth.test.mjs
1..16
# tests 16
# suites 0
# pass 16
# fail 0
# cancelled 0
# skipped 0
# todo 0

The sixth test is the one the page rests on: entries list answers 401 with no input, 200 with --api-key, and 200 again with LEDGER_TOKEN set and no flag. The seventh fetches a token with the right client secret and gets 201, then fails before any API request when the secret is wrong. The ninth renames both apiKey parameters and checks that the debug log still never holds the key.

When it goes wrong

A secret arrives as a positional argument, or as a flag, and another user reads it. The command line of every process is in /proc/<pid>/cmdline, which the proc manual describes as the complete command line for the process, and ps reads it.2 The fix is the environment variable, which the CLI already accepts, and a help line that says so.

A debug flag prints the request and the token with it. A CI job captures stderr, so anyone who can open the job log can use the token until it is rotated. The fix is to redact every credential before it reaches the log, which cli.mjs --debug does by keeping the last four characters. It finds each one under the name its scheme gives it, so a document that calls its key header X-Ledger-Key is covered too.

A scheme has no input. An authorization-code or device-code flow needs a browser or a second device, and inputsFor returns null for them. The CLI names the scheme and stops, instead of sending the request bare and letting the server explain.

node pitfall.mjs
what /proc/<pid>/cmdline holds for three ways of passing the same token
  ledger entries list tok_test_91c2 (a positional secret) cmdline: node cli.mjs entries list tok_test_91c2
                                                         the token is in cmdline, mode 444, readable by every user on the host
  ledger entries list --token tok_test_91c2              cmdline: node cli.mjs entries list --token tok_test_91c2
                                                         the token is in cmdline, mode 444, readable by every user on the host
  LEDGER_TOKEN=tok_test_91c2 ledger entries list         cmdline: node cli.mjs entries list
                                                         the token is in environ, mode 400, readable by its owner and root

what a debug log prints for one request
  redacted, as cli.mjs --debug prints it:   authorization: Bearer ****91c2
  unredacted, as a bare header dump would:  authorization: Bearer tok_test_91c2
  a CI job that captures stderr keeps that line for as long as it keeps the log

When not to do this

Do not accept a secret as a positional argument. A flag leaks the same way, through the process table, but a flag at least announces itself as a credential in --help and can be refused in favor of the variable. A positional secret looks like an id.

Do not log a request at debug level without redaction. The sample keeps four characters, enough to tell two tokens apart and not enough to use either.

Do not build this when restish fits. It reads the same securitySchemes, supports seven auth types, and redacts its saved profile on api inspect.3 A team that wants a CLI over its own API and does not need to ship a binary gets all of this for one api connect.

Do not treat security: [] as a gap in the document. It is the specification’s way of saying an operation is open, and a CLI that demands a credential for health has invented a requirement.

Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it printed, against the node:http server in the sample directory. HTTPie and restish were not run; their behavior is described from the documentation linked on this page.

Footnotes

  1. RFC 6750 defines three ways to send a bearer token and then argues against one of them. The query-parameter method, it says, SHOULD NOT be used unless it is impossible to transport the token in the Authorization header or the request body. The reason given is the high likelihood that the URL containing the access token will be logged. It then adds that the method is included to document current use. ↩︎ Back to text

  2. The proc_pid_cmdline page says the file holds the complete command line for the process, unless the process is a zombie, in which case a read returns nothing. The arguments appear in the same layout as they do in process memory, separated by null bytes. That is why ps can print them, and why a token typed after --token reaches anyone who runs it. ↩︎ Back to text

  3. The restish API management reference says api inspect redacts sensitive auth values, credential-like headers, and credential-like query parameters. Its api auth inspect, documented beside it, redacts only when asked with --redact. Two commands on one page, one redacting by default and one on request, which is the kind of detail a CLI author copies without reading. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.