steps docs
start here Writing pipelines resourceswebhooksexprcontrol-flowagentsattempts-timeoutworkspaceinfratemplatingmcpcomplete Reference webobservabilityauthenticationagents-internalsaws-workersgcp-workersdo-workersconformance

Authentication

steps web asks for nothing by default, and on the loopback address it binds by default that is the right answer: the UI and the terminal that started it are the same trust domain. The moment the daemon is reachable from somewhere else, it is not — a pipeline is arbitrary commands, so steps pipeline set is a remote shell (web.md, Security).

Accounts close that: local accounts the daemon keeps, or a GitHub or Google account. The CLI carries a local one as HTTP Basic in its --target URL, a browser signs in through a form or with GitHub or Google, each request is recorded under the account that made it, and roles decide what each account may do.

Turning it on

steps web \
  --basic-auth-username ops \
  --basic-auth-password "$(openssl rand -base64 24)"
FlagEnvironment variable
--basic-auth-usernameSTEPS_BASIC_AUTH_USERNAME
--basic-auth-passwordSTEPS_BASIC_AUTH_PASSWORD

Prefer the environment variables in a deployment: a password on a command line is in ps output and in shell history.

Both or neither. A daemon given only one of them refuses to start, rather than serving open access to somebody who believes it is protected.

Several people: local users

htpasswd -nbB alex "$ALEX_PASSWORD" >  users
htpasswd -nbB sam  "$SAM_PASSWORD"  >> users

steps web --local-users-file users
steps web --local-user 'alex:$2y$05$…'      # repeatable; the same line htpasswd writes

Each account is a name and a bcrypt hash — the daemon never holds the password. A line whose password is not a bcrypt hash is refused at startup, saying how to make one and never repeating what it was given. A name given twice, across both flags or alongside --basic-auth-username, is refused too: which password worked would otherwise be an accident of order. Blank lines and # comments in the file are skipped.

Everyone signs in the same way — the browser prompts, the CLI carries name:password@ in --target — and what each one does is recorded under their own name: a run they trigger says by alex (local) (web.md). The --basic-auth-* pair still works, and is one more account beside these.

bcrypt is slow on purpose (tens of milliseconds a check) and a browser sends credentials on every request, so a pair that checked out is remembered for a minute. A changed file reaches a running daemon only on restart, and an account removed from it then ends: its browser sessions and its agents' connections stop working.

Unset is open access, which is what every local workflow is. steps web on a routable address with no credentials logs a warning once at startup (web.no_auth) naming --local-user (or the --basic-auth-* pair) and --allowed-host; it does not refuse, because an operator who has put the daemon behind a tunnel or a reverse proxy has already answered the question.

Signing in from a browser

A browser that is not signed in is sent to /login, a form asking for an account and its password, and on to the page it asked for once signed in. The session lasts a week and lives on the daemon — the cookie holds a random token, and the state database holds only a digest of it — so sign out, in the menu under your name, ends it everywhere at once: a copied cookie stops working too.

Signing in with GitHub or Google

export STEPS_GITHUB_CLIENT_SECRET=…        # from the OAuth app you registered
export STEPS_GOOGLE_CLIENT_SECRET=…
steps web --external-url https://steps.example.com \
  --github-client-id Iv1.0123456789abcdef \
  --google-client-id 1234-abc.apps.googleusercontent.com \
  --role 'admin=Provider == "github" && ID == "583231"' \
  --role 'viewer="acme" in Organizations'

Each client id adds a Continue with … button to the sign-in page. Register the daemon with the provider first, giving it <external-url>/auth/github/callback or <external-url>/auth/google/callback as the redirect address.

GitHub and Google let in anybody with an account there, so a daemon offering either refuses to start without roles that say which of those accounts may do what: an admin rule, and a viewer rule. Without them every GitHub or Google account would be an admin, or could read every pipeline.

Who may do what: roles

steps web --local-users-file users --github-client-id … \
  --role 'admin=Provider == "local" && UserID == "jt"' \
  --role 'operator="acme/eng" in Groups' \
  --role 'viewer="acme" in Organizations || Email endsWith "@acme.dev"'

Three roles, Concourse's, each including the one below it:

RoleMay
viewerread every page
operatortrigger, retry, approve, reject, answer, abort, release, pause and unpause
adminset, rename and destroy pipelines, connect and test MCP servers, and read the activity log

Each --role is a rule about the signed-in person, an expr expression over Provider (local, github, google), UserID (the account name, GitHub login or Google address), ID (the provider's own id for the account — GitHub's numeric user id, Google's subject, a local account's name), Email, Organizations and Groups.

UserID and ID differ only for GitHub, where a login can be renamed and then registered by somebody else. A rule naming UserID == "alice" follows the login; one naming ID == "583231" follows the account, whatever it is called. Use whichever you mean. A person has the highest role whose rule holds.

One pipeline's own rules: access:

A pipeline can narrow who may see and operate it, in the same rule language:

access:
  viewer: '"acme" in Organizations'      # anybody else is told it does not exist
  operator: '"acme/oncall" in Groups'    # anybody else who may see it only reads

jobs:
- name: deploy
  plan:
  - task: ship
    run: echo shipped
  assert:
    execution: [ship]
    outcome: succeeded

What it covers

Every route: the pages, /docs, the live-run SSE streams, and the /api endpoints steps pipeline, steps runs abort and steps mcp login -p talk to. A browser navigating to a page is sent to /login; a page's own refresh gets a bare 401; anything else gets a WWW-Authenticate: basic realm="steps" challenge. /login, the GitHub and Google sign-in redirects (/auth/<provider> and its /callback), and the stylesheet and scripts the form is drawn with answer anyone: nobody can sign in through a page that needs them signed in first.

The endpoints an agent uses to reach an mcp_exports: entry — the OAuth metadata under /.well-known/, /oauth/register, /oauth/token, and the export itself at /mcp/x/<pipeline>/<export> — skip this check too, because the agent authenticates with the token a person granted it, not with a session. /oauth/authorize, where that person signs in and agrees, does not.

One route is exempt: POST /p/<pipeline>/hooks/<resource>, a webhook delivery. A sender has no credentials to give and authenticates with its own signature instead — the same reason that route is exempt from the same-origin check and is not withheld by --read-only. A delivery with a bad signature is still refused by the hook, with a 401 of its own.

A second route is exempt for the same reason: GET /mcp/callback, where an authorization server sends the browser to finish a steps mcp login against this daemon. A redirect is a navigation a third party caused and carries no credential of yours; it is authenticated by the single-use state its login minted, and anything else is a 404. Starting that login, and reading the URL that carries the state, both need credentials.

A third answers anyone: GET /healthz, which says ok and nothing else, for a platform's health check — which sends no credentials. Liveness is all it reveals, and it is never traced (see observability.md).

What a browser may start

/api refuses anything browser-shaped, so until the mcp tab existed a browser could not begin an oauth login at all — only the CLI could, over /api. The tab's Connect and Test buttons deliberately open a browser-reachable way to do both: POST /p/<pipeline>/mcp/<server>/connect starts a login, and POST /p/<pipeline>/mcp/<server>/test connects to a declared server. This is a real change in posture and not a side effect, so it is stated here rather than discovered.

What bounds it:

Basic auth does not replace the /api browser refusal described in web.md: a request carrying Origin or a browser-shaped Sec-Fetch-Site is still refused whatever credentials it has, because a page that talked an operator into typing a password must still not be able to set a pipeline.

From the CLI

Every command that talks to a daemon takes --target (or STEPS_TARGET). Credentials go in that URL's userinfo:

steps pipeline set -c pipeline.yml --target https://ops:[email protected]
export STEPS_TARGET="https://ops:[email protected]"
steps pipeline list

A password holding a @, a : or a / has to be percent-encoded, as in any URL.

They travel in an Authorization header, never in the request URL, and the userinfo is stripped from every message the CLI prints — a refusal, a timeout, an unreachable daemon — so a password that reached a terminal, a CI log or a pasted bug report would be a defect.

A request with no credentials where credentials are required says what to do about it rather than repeating Unauthorized:

$ steps pipeline list
steps: error: https://steps.example.com asked for credentials it did not get —
put them in the target URL: --target http://user:password@host (or STEPS_TARGET)

What it is not