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

Source: https://voxgig.com/howto/wire-security-schemes-into-cli-auth-flags

- Audience: api-producer
- Level: intermediate
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](/howto/map-api-parameters-to-cli-flags-and-arguments).
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](https://spec.openapis.org/oas/v3.1.0.html#security-scheme-object) | 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](https://httpie.io/docs/cli/authentication) | 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](https://rest.sh/docs/reference/auth/) | 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](https://spec.openapis.org/oas/v3.1.0.html#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](https://spec.openapis.org/oas/v3.1.0.html#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.

```js title="auth.mjs"
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.

```bash
node auth.mjs
```

```text output
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](https://www.rfc-editor.org/rfc/rfc6750.html#section-2.1) makes the method every
resource server must support.[^1] Basic credentials go in `Authorization: Basic` as the base64 of
`user:password`, per [RFC 7617](https://www.rfc-editor.org/rfc/rfc7617.html). Client credentials
are posted to the `tokenUrl` with `grant_type=client_credentials`, as
[RFC 6749](https://www.rfc-editor.org/rfc/rfc6749.html#section-4.4) 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.

```js title="auth.mjs"
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.

```bash
node cli.mjs --help
```

```text output
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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test auth.test.mjs
```

```text output
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](https://man7.org/linux/man-pages/man5/proc_pid_cmdline.5.html) 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.

```bash
node pitfall.mjs
```

```text output
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

- [Derive a noun-verb command tree from an OpenAPI document](/howto/derive-a-command-tree-from-operation-ids)

- [Write CLI help that people and agents can both use](/howto/help-text-people-and-agents-can-use)

- [Design a CLI a coding agent can drive](/howto/design-a-cli-a-coding-agent-can-drive)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

- [Map API parameters to CLI arguments and flags](/howto/map-api-parameters-to-cli-flags-and-arguments)

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

[^1]: [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750.html#section-2.3) 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.

[^2]: The [`proc_pid_cmdline` page](https://man7.org/linux/man-pages/man5/proc_pid_cmdline.5.html)
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.

[^3]: The `restish` [API management reference](https://rest.sh/docs/reference/api-management/)
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.