Skip to main content
By the end of this page your clients are signed in — people on their own credentials, machines on tokens — and you know how the daemon decides which requests it accepts. Two kinds of principal reach a server, and they carry two kinds of credential. A person signs in on the server’s own page with whatever the server’s identity plane accepts — a password today, an identity provider where one is configured. A machine — an MCP agent, a CI job, a device an administrator sets up by hand — carries a token, a device credential minted for it. A native client never asks a person for their password.

How a person signs in from a client

The extension, the desktop app and the CLI sign a person in the way the standard names it: the server is the authorization server for its own clients (OAuth 2.0 — no third party involved), each client is a registered public client of it, the browser is the only place a credential is ever typed, and the client receives an access token bound to that person. Nothing is pasted into the client, and the password never leaves the browser. Two grants are served, and each client runs the one it can:
  • The authorization code grant with PKCE — the desktop app and the extension. The client opens the server’s consent page in the browser; once the person approves, the browser is sent back to the client’s registered return address with a one-shot code that only the client that asked can redeem. The desktop app returns on its own loopback callback; the extension in the window its browser’s identity API opened, which closes by itself.
  • The device authorization grant — the CLI, and the extension on a browser without an identity API. The client shows a short code (BCDF-GHJK) and a link; the person opens the link on any device, signs in, checks that the page shows the same code, and approves. The client polls until then.
Either way the browser shows one consent page: the device that is asking — its label (Chrome · macOS, the machine name, --label) and the kind of client — the code on the device grant, Allow and Not me. Where the web app is served and reached at a secure address (https, or loopback) the page is a card inside the web app: a browser already signed in there approves in one click, and one that is not sees the usual sign-in first — the password form, or the identity provider’s button — and comes back to the card. Where the web app cannot run — a plain-http LAN address, or a server that serves no web bundle — the server renders the same page itself, with the email and password form on it. In the extension or the desktop app: Settings → Backup and Sync → Sign in to a server…, enter the server’s address, and on the sign-in step click Sign in on …. Finish the sign-in in the browser; back in the wizard the line reads Signed in as you, and Connect finishes the join. From the CLI:
oh login prints the link to open and the code the page will show, opens the link when a browser is within reach (never over SSH or without a display — paste the link anywhere then), waits for the approval, and saves the credential to cli.json exactly as oh connect --token would. --label <name> names the device on the consent page. Each approved device is its own session: it appears under Paired devices in the admin console as a session bound to that person, expires after the server’s sessionTtlDays (default 30 — the one policy every session on the server follows), and can be revoked on its own. Deactivating the person revokes all of theirs at once. When a session expires, the client asks to sign in again. What the page shows follows the server’s state: an unclaimed server says it has no administrator yet and points at the claim; a server whose login is the password shows the email and password form; a server with an identity provider shows the provider’s button; a server where nobody can sign in from a browser (no password holder, no provider) says so and points at the admin-issued path below. The server publishes its metadata at /.well-known/oauth-authorization-server, and the three clients it knows are openheaders-desktop, openheaders-extension and openheaders-cli — there is no registration for others, and no refresh token: a session that expires is signed in again.
Only the client that asked can ever redeem an approval: the code grant’s secret never leaves the client, and the device grant’s approval is bound to the device code the client holds. The page never shows a secret. A code you did not ask for is somebody else’s device — check that the code matches the one your device shows, and click Decline (Not me on the server’s own page) if it does not. A declined sign-in goes back to the client the way an approval does, carrying the refusal instead of a code, so the browser window the client opened closes on a no as it does on a yes.

Tokens for machines and admin-issued devices

A token is the credential for a client with no person to sign in — an MCP agent, a CI job — and for a device an administrator sets up on someone’s behalf. On a claimed server they come from the admin console: sign in at the server’s URL, open Settings → Backends → Open admin console, and use Paired devices to either
  • Generate token — mint a secret to paste into a client, optionally bound to a directory user, or
  • Pair a device — show a short code the device enters under the wizard’s Have a pairing code or token from an administrator? link, for when someone else sets the device up.
Both add an entry to the same ledger, and both can be rotated or revoked from that list. Revoking disconnects whatever was using the token immediately. A token bound to a user confers exactly that user’s grants; an unbound one acts as the server operator. For the CLI:

The machine bootstrap

show-token mints a token straight against the daemon’s data dir and prints it with every URL a client might join at. It exists for one case: attaching a native client to a headless box before any browser is involved — no console to open yet, or none you can reach. It is not how people get into the server, and it is not the first step of a normal install. It requires the daemon to be stopped (storage.json is single-writer) and the secret is shown once.
A token minted this way with no --user is unbound: it acts as the server operator, with full administrative power. Claiming the server revokes every unbound token and disconnects whatever was using it — that is deliberate, since leaving them alive would be a way around the administrator the claim just created. Bind the token to a directory user with ohd show-token --user <id-or-email> if it should survive a later claim, or simply pair the device again from the console afterwards.

What tokens gate

Tokens are required on every non-loopback connection — LAN and proxied alike. The token-gated surfaces:
  • WebSocket sync (every client connection)
  • /mcp (bearer token; see the MCP quick start)
  • /metrics (bearer token, loopback included)
/healthz stays open — that is what ohd status probes.

Admission rules

Every route on the bind enforces its own Origin/Host posture:
  • /mcp refuses any browser-originated request outright.
  • The WebSocket sync route accepts browser-extension origins and the daemon’s own served origin.
  • The consent pages — the authorize entry, the verify page, the server-rendered consent page — and the pairing page accept top-level navigations and same-origin form posts; the decision routes take the web app’s session or the page’s own form. The routes a client itself calls — the sign-in server’s metadata, the device grant’s start, the token and revocation endpoints, confirming a pairing code, and the three /auth/*/meta reads — also accept the extension’s own origin; the desktop app and the CLI carry no Origin at all.
  • The web app pages accept top-level navigations and same-origin fetches; the sign-in and setup routes (/auth/oidc/*, active only when configured; /auth/password/*; /auth/setup/*) behave the same.
  • /healthz stays open.
Requests addressed by a hostname the daemon doesn’t answer as are refused on the browser-facing routes — IP addresses, localhost, and mDNS *.local names always work; anything else (a reverse-proxy domain, an intranet name) must be declared with --allowed-host. Refusals are logged as rejected <route> request: <reason> (origin=… host=… peer=…).

Rate limits

Failed credential attempts — pairing-code guesses, codes the token endpoint does not know, WebSocket auth rejections, /mcp bearer failures, refused sign-ins (on the web app and on the consent page alike) and refused claims — feed one per-peer budget. A peer that crosses it is blocked for a cool-down: HTTP requests answer 429 with a Retry-After header and the body {"error":"too many failed attempts"}; upgrades are refused. The daemon logs a single line at the transition:
A mistyped name or too-short password on the setup card is not a failed attempt — it discloses nothing about the server, and a typo must not lock out the person claiming their own box. Behind a reverse proxy, set --trusted-proxy so the budget applies to the real client address from X-Forwarded-For, not the proxy’s own — see LAN vs TLS proxy. The same setting decides whether a claim counts as loopback: without it, every client arriving through a proxy on the same machine would look local.