---
title: Choosing a CLI authentication path
description: Decide between interactive OAuth (no CLI subcommand today), M2M client_credentials, and a one-off bearer token for the huli CLI — a decision table and the exact command for each.
nav: 'Recipes'
order: 70
version: v1
source: handwritten
updated: 2026-06-02
---

# Choosing a CLI authentication path

The `huli` CLI talks to the same FHIR R4 surface three different ways, and the right
one depends on who runs it and how long it runs. Pick the path first, then copy the one
command that matches. This recipe maps each workload to a path, gives you the exact
invocation, and names the failures that tell you the path was wrong.

## Audience

You wire integrations against the Huli FHIR API and you are about to script the
`huli` CLI into something — a developer's laptop, a cron job, a CI pipeline, or a quick
one-shot from a shell. You know what a bearer token is and you have read the FHIR base
URL at least once.

## You'll need

- The `huli` CLI on your `PATH`, with `huli auth setup` available (run `huli --help` to
  confirm the binary resolves).
- For the M2M path: an `api_key` registered with a JWKS URI, an RS384 signing key whose
  public half is published at that JWKS URI, and the scopes the workload needs. An admin
  on your organization registers the key in **Practice Settings → Integrations → API
  Keys**.
- For the one-off path: an admin bearer token, minted once in the same place and shown
  once. It is long-lived and scoped to one organization.
- The base host `https://api.huli.ai` and the FHIR base `https://api.huli.ai/fhir/R4/`.
  The token endpoint is host-rooted at `https://api.huli.ai/auth/token`, not under
  `/fhir`.

<Callout variant="info">
SMART discovery and JWKS are issuer-rooted under <InlineCode>/fhir</InlineCode>, not the
host root: discovery at
<InlineCode>https://api.huli.ai/fhir/.well-known/smart-configuration</InlineCode> and the
server's signing keys at
<InlineCode>https://api.huli.ai/fhir/.well-known/jwks.json</InlineCode>. The token POST,
by contrast, is host-rooted at <InlineCode>https://api.huli.ai/auth/token</InlineCode>.
Mixing these up is the most common first-run misconfiguration.
</Callout>

## End state

You have chosen one of three paths and run one authenticated request through it. The CLI
holds credentials in the shape that path expects — a stored M2M profile, an interactive
session, or a bearer string passed per command — and a Patient search returns a
`searchset` Bundle instead of an `OperationOutcome`.

## Steps

### 1. Match your workload to a path

Read the row that describes who runs the CLI and how often, then jump to that path's
command below.

| Workload                                                                             | Path                                                                     | CLI surface                              | Credential lifetime                                                  | Status                  |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ---------------------------------------- | -------------------------------------------------------------------- | ----------------------- |
| A human running the CLI interactively from a laptop, acting as themselves            | Interactive OAuth (Auth-Code + PKCE)                                     | `huli auth login`                        | Short-lived session, refreshed in the background                     | No CLI subcommand today |
| An unattended server, cron job, or CI pipeline acting as a service, not a person     | SMART Backend Services (`client_credentials` + `private_key_jwt`, RS384) | `huli auth setup`                        | 5-minute access token, re-minted automatically from your signing key | Available               |
| A one-shot command, a debugging session, or a script that already holds an admin key | Admin bearer token passed per command                                    | `--token` flag or `Authorization` header | Long-lived admin key, no refresh                                     | Available               |

The dividing questions, in order:

1. Is a person sitting at the keyboard, and do you want requests attributed to that
   person? That is interactive OAuth — a v1 API auth mode the CLI has no subcommand for
   today, so fall through to one of the next two.
2. Is the caller a service running without a human present? That is `huli auth setup`.
3. Is this a single throwaway call, or do you already hold an admin bearer token? Pass
   it per command with `--token`.

### 2a. Interactive OAuth — no CLI subcommand today

<Callout variant="warning">
<InlineCode>huli auth login</InlineCode> is not part of the current CLI. Interactive OAuth
(Auth-Code + PKCE) is a defined v1 auth mode on the API, but the CLI has no subcommand
that drives it. A human who needs to run the CLI uses an admin bearer token (step 2c)
scoped to what they need.
</Callout>

Treat the interactive row in the decision table as a signpost, not a CLI instruction: for
CLI access today, use M2M (`huli auth setup`, step 2b) or an admin bearer token (step 2c).

### 2b. M2M — `huli auth setup` (client_credentials + private_key_jwt)

This is the path for any unattended caller. The CLI signs a `private_key_jwt` client
assertion with your RS384 key, exchanges it at the token endpoint for a 5-minute access
token, and re-mints that token automatically as it expires.

Run the one-time setup:

```bash
huli auth setup \
  --base-url https://api.huli.ai \
  --client-id clinica-san-rafael-integration \
  --jwks-uri https://integrations.clinica-san-rafael.example/jwks.json \
  --private-key ./san-rafael-signing-key.pem \
  --scope "system/Patient.rs system/Appointment.rs"
```

The flags map one-to-one onto the SMART Backend Services handshake:

- `--base-url` is the host root. The CLI derives the token endpoint as
  `https://api.huli.ai/auth/token` and discovery as
  `https://api.huli.ai/fhir/.well-known/smart-configuration` from it.
- `--client-id` is the `sub` of your signed assertion and identifies the `api_key` row.
- `--jwks-uri` must match the JWKS URI registered on that `api_key` — the server fetches
  your public key from there to verify the RS384 signature.
- `--private-key` points at the RS384 private key whose public half lives at that JWKS
  URI.
- `--scope` is the space-separated set of scopes to request. Request only what the
  workload uses.

Once setup completes, every CLI command authenticates from the stored profile — no token
flag, no header. Run any read command (a Patient search filtered by name, for example)
and behind it the CLI POSTs the assertion to
`https://api.huli.ai/auth/token`, receives an RS384 access token valid for 5 minutes,
and attaches it as a bearer on the FHIR request. When the token expires, the next command
re-mints it from your key — you never handle the access token yourself.

Pick scopes from the v1 set. Letters are `r`=read, `s`=search, `c`=create, `u`=update.
Read plus search is `.rs`; full write is `.cru`.

- Read + write resources: <Scope name="system/Patient.cru" />,
  <Scope name="system/Appointment.cru" />, <Scope name="system/Encounter.cru" />,
  <Scope name="system/Observation.cru" />.
- Read-only resources: <Scope name="system/Practitioner.rs" />,
  <Scope name="system/Organization.rs" />.
- Provenance is create plus read plus search only, client-POSTed:
  <Scope name="system/Provenance.rs" /> together with
  <Scope name="system/Provenance.c" />.

### 2c. One-off — admin bearer token via `--token` or header

For a single call, a debugging session, or a script that already holds an admin key,
skip the stored profile and pass the token per command. The admin bearer token comes
from **Practice Settings → Integrations → API Keys**, is shown once, and is long-lived.

Export it so it never lands in shell history:

```bash
export HULI_API_KEY="<paste your admin bearer token here>"
```

Pass it to the CLI with the `--token` flag on any read command, or — calling the API
directly rather than through the CLI — ride the same token in the `Authorization` header
with one space after `Bearer`:

<Endpoint method="GET" path="/fhir/R4/Patient?name=Fern%C3%A1ndez&_count=20" />

```bash
curl "https://api.huli.ai/fhir/R4/Patient?name=Fern%C3%A1ndez&_count=20" \
  -H "Authorization: Bearer $HULI_API_KEY" \
  -H "Accept: application/fhir+json"
```

<Callout variant="note">
The admin bearer token does not expire on a timer the way the M2M access token does, so
there is no refresh to manage — but that also means a leaked admin token stays valid
until an admin revokes it. Keep it to one-off and interactive-human use; for anything
unattended and long-running, prefer the M2M path, where each access token lives 5 minutes.
</Callout>

## What to verify

- For the M2M path: `huli auth setup` exits cleanly, and a follow-up read command (a
  Patient search by name) with no `--token` flag returns a `Bundle` of type `searchset`.
  That proves the stored profile minted a token without you handling it.
- For the one-off path: the same read with `--token "$HULI_API_KEY"` returns a
  `searchset` Bundle. Status is `200`. <StatusBadge code="200" />
- Either way, the response `resourceType` is `Bundle` and `type` is `searchset` — not
  `OperationOutcome`.
- On the M2M path, you requested only the scopes the workload uses. A read-only reporting
  job should not request `.cru` on any resource.

## What can go wrong

Every failure comes back as a FHIR `OperationOutcome` with exactly this shape — no
`details`, no `coding`, no `text`:

```json
{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "security",
      "diagnostics": "HPB-00106: Authentication failed"
    }
  ]
}
```

Classify machine-side on the HTTP status and `issue[0].code` (the FHIR IssueType:
`400`→`invalid`, `401`→`security`, `403`→`forbidden`, `404`→`not-found`, `409`→`conflict`,
`429`→`throttled`, `5xx`→`exception`). The Huli code is the prefix of
`issue[0].diagnostics` — split on `": "` to read it. The five you will hit choosing a
path:

<StatusBadge code="401" /> `HPB-00106` — auth failed. The credential is missing,
malformed, or revoked. On the M2M path, the assertion signature did not verify — confirm
`--jwks-uri` matches the URI registered on the `api_key` and that `--private-key` is the
RS384 key whose public half sits there. On the one-off path, confirm the header reads
`Authorization: Bearer <token>` with a single space and that `$HULI_API_KEY` is exported
in this shell.

<StatusBadge code="401" /> `HPB-00107` — auth expired. You presented an access token past
its lifetime. This is specific to the 5-minute M2M token: it means a cached token outlived
its window. With `huli auth setup` the CLI re-mints automatically, so seeing this usually
means you pinned a raw token by hand instead of letting the stored profile refresh it.
You will not see this from an admin bearer token, which does not expire on a timer.

<StatusBadge code="403" /> `HPB-00104` — insufficient scope. The credential authenticated
but lacks the scope the request needs. On the M2M path, widen `--scope` and re-run
`huli auth setup` (and confirm the `api_key` is allowed those scopes). On the one-off
path, confirm the admin key was granted the scopes the request needs; if not, mint a new
key in **Practice Settings → Integrations → API Keys**.

<StatusBadge code="400" /> `HPB-00101` — validation error. A request parameter is
malformed — most often an un-encoded accent in a hand-built URL. Encode `á` as `%C3%A1`,
or let the CLI and HTTP clients encode the raw string for you.

<StatusBadge code="429" /> `HPB-00105` — rate limited. You exceeded the per-key request
budget. Read the `Retry-After` response header and back off for that many seconds before
retrying. Unattended M2M jobs should honor `Retry-After` rather than tight-looping.

## Next recipes

- **Run your first authenticated Patient search** — one round-trip against the FHIR
  API with an admin bearer token, and the four errors you hit first.
- **Authenticate as a SMART Backend Service** — the full `client_credentials` +
  `private_key_jwt` (RS384) handshake under the hood, for when you want to build the
  token exchange yourself instead of letting the CLI drive it.
- **Paginate a large patient list** — follow the `Bundle.link` entry whose relation is
  `next` to walk every page of a `searchset`, whichever auth path you chose here.
