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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Hand-written auth flags | You ship the CLI and want its flags, help, and errors in your own words | Every scheme type coded by hand, and a mapping kept in step with the document as schemes are added | The CLI is generated at run time from the document, where restish does this for you |
HTTPie -A auth types | One-off calls from a shell, with basic, digest, or bearer, and plugins for the rest | One -a value per call, typed again or kept in a session file in plain text, with no reading of the document | You ship a CLI to other people, or the API wants client credentials |
restish auth types | Any API with an OpenAPI document, and seven auth types configured once per profile | A profile per API in the restish config file, and an auth model that belongs to restish rather than to you | You 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.
Related how-tos
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
-
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
Authorizationheader 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 -
The
proc_pid_cmdlinepage 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 whypscan print them, and why a token typed after--tokenreaches anyone who runs it. ↩︎ Back to text -
The
restishAPI management reference saysapi inspectredacts sensitive auth values, credential-like headers, and credential-like query parameters. Itsapi 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