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)"
| Flag | Environment variable |
|---|---|
--basic-auth-username | STEPS_BASIC_AUTH_USERNAME |
--basic-auth-password | STEPS_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.
- A wrong name and a wrong password get the same sentence,
couldn't sign you in, and the name you typed is kept. - After signing in you are sent only to a page on this daemon; anything else goes to
/. - The cookie is
HttpOnlyandSameSite=Lax, andSecurewhen the daemon is reached over https (directly, or through a proxy that setsX-Forwarded-Proto). - A page's own refresh is never redirected to the form, or challenged: a session that ends while a page sits open answers
401, and the page stops refreshing and sayssigned out · sign in againwhere it lists what needs you, linking back to itself. - The CLI is unchanged: it sends its account on every request and never sees the form.
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.
- The secret is read from the environment, never a flag:
$STEPS_GITHUB_CLIENT_SECRETand$STEPS_GOOGLE_CLIENT_SECRETby default, or the variable--github-client-secret-env/--google-client-secret-envname. An id without its secret, or with no external URL to send the browser back to —--external-url, orhttp://<listen>when--listennames a specific address rather than0.0.0.0— stops the daemon before it listens. - Who they are:
alex (github)by GitHub login,[email protected] (google)by email. - What is read about them, at every sign-in, replacing what was read last time: from GitHub, their organizations and
org/teamteams (the app asks forread:org); from Google, their email (refused unless Google has verified it) and, for a Workspace account, its domain. Google says nothing about groups without its admin API, so a rule about a Google account can match its address or domain and nothing else. - Local accounts and providers mix. The password form is drawn only when the daemon keeps local accounts. The CLI still needs a local account: a GitHub or Google sign-in is a browser's.
- A sign-in that comes back with a state this daemon did not hand out, or one already used, is refused.
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:
| Role | May |
|---|---|
viewer | read every page |
operator | trigger, retry, approve, reject, answer, abort, release, pause and unpause |
admin | set, 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.
- No
--roleat all is the default for a daemon with only local accounts: everyone signed in may do everything, because everyone signed in holds a password you gave them. A daemon offering GitHub or Google sign-in refuses to start this way. - With any
--role, anadminrule is required, or nobody could set a pipeline and the daemon would be stuck as it stands.operatorleft out means only admins act. Aviewerrule left out lets anyone signed in read — allowed with local accounts only; with GitHub or Google sign-in aviewerrule is required too. - A rule that does not compile stops the daemon before it listens, naming the role, and so does a role given twice, or
--roleon a daemon nobody can sign in to. - It is enforced by the daemon, on every request, not by hiding buttons. A viewer's pages leave the controls out and say once, in the action bar, what the operator role would add; a request for something their role does not cover is refused with
403, from a browser or from the CLI alike. - Rules read the daemon's own record of the person, written at their last sign-in — never anything the browser carries. That is what keeps an exclusion honest:
operator=!("acme/contractors" in Groups)reads the groups GitHub reported, so a contractor cannot pass by arriving with none. Leaving a team takes effect at the next sign-in, within a week. --read-onlystill withholds the controls from everyone, whatever their role.
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
- It only narrows. A pipeline is a file anybody with a pull request can change, so nothing it says can give a person more than the daemon's
--rolerules do: anoperator: "true"lets nobody trigger who could not already. Both have to agree. - Admins are not narrowed — they can set or destroy the pipeline anyway.
- Someone it hides is told it does not exist: its pages answer
404, and it is missing from the pipeline switcher, the root page and the jump palette. A pipeline that will not load has no rules to read, so only admins see it listed as broken. - A rule that does not compile refuses the set, naming the field, on the daemon that would have served it.
- It applies where people sign in. A daemon nobody signs in to, and
steps run, have nobody to narrow.
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:
- Both routes require the daemon's credentials, like every other page route.
sameOriginMutationsrefuses a cross-site POST, which is what stops another page aiming your browser at these routes.- Neither can name a server the pipeline does not already declare. Adding one takes
steps pipeline set, which is remote-shell-grade already — so the blast radius is the set of servers somebody with that access chose. --read-onlywithholds both, while keeping the page: seeing that a server needs a login is a diagnostic, and starting one is a control.- Where a finished login sends the browser is a path on this daemon, refused if it carries a scheme or a host, so the route cannot be used as an open redirector.
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
- Not a substitute for TLS. Basic auth sends the password on every request, base64-encoded and not encrypted, and a session cookie is as good as the password while it lasts. Over plain HTTP anything on the path reads it. Terminate TLS in front of the daemon — a platform's own router, a reverse proxy, a tunnel.
- Not rate limited. There is no lockout and no delay on a wrong password. Use a generated password, not one somebody chose.
- Not a reason to expose the daemon. The set endpoint is still a remote shell; authentication decides who reaches it, not what they can do once they have.