--- title: Authentication description: How you prove who you are to Openship — session cookies, personal access tokens, MCP OAuth, and desktop loopback mode. --- import { TypeTable } from 'fumadocs-ui/components/type-table'; Every request to Openship has to answer one question first: **who are you?** Openship accepts four different ways to answer it, and which one applies depends on *where* the request comes from — a browser tab, a script, an AI agent, or the desktop app talking to itself. This page walks through each one. Every API route lives under **`/api`** on your instance — e.g. `https://your-host/api/projects`. The same authentication rules apply to all of them; there is no unauthenticated back door. Access checks after login are covered separately in [Permission model](/docs/security/permissions). ## The four ways in | Method | What you send | When it applies | |---|---|---| | **Session cookie** | An httpOnly cookie the browser holds for you | The dashboard, in a normal browser | | **Personal access token (PAT)** | `Authorization: Bearer opsh_pat_…` | The CLI, scripts, server-to-server, and MCP clients without OAuth | | **MCP OAuth** | An OAuth 2.1 access token the client fetches for you | AI agents (Claude, Cursor, …) connecting to `/api/mcp` | | **Zero-auth loopback** | Nothing — the request just comes from `127.0.0.1` | The desktop app, or a local single-user instance you opt into | A single request uses exactly one of these. Openship checks for a `Bearer` token first; if there isn't one, it looks for a session cookie; if there's neither, it falls back to the loopback path (and only if that mode is turned on). ## Session cookies (the dashboard) When you log in to the dashboard, Openship gives your browser a **signed, httpOnly session cookie**. "httpOnly" means JavaScript running in the page cannot read it — so even if a bad script somehow ran on the page, it couldn't steal your session. The browser sends the cookie automatically on every request; you never handle it yourself. The cookie name is prefixed with your instance mode — `openship.session_token` on a self-hosted install, `openship-cloud.session_token` on Openship Cloud. If a request arrives with a `Bearer` header **and** an `Origin` matching one of the dashboard's trusted browser origins, Openship rejects it (`401 BEARER_NOT_ALLOWED_FROM_BROWSER`). Bearer tokens are for CLIs and servers; a token showing up from a browser tab is the signature of a stolen credential being replayed, so the door is closed. The CLI sends no `Origin` header at all, which is why its tokens are accepted. ## Personal access tokens (Bearer) A **personal access token** (PAT) is a long string you send in the `Authorization` header to prove who you are without a browser: ``` Authorization: Bearer opsh_pat_… ``` Every token looks like `opsh_pat_` followed by a 43-character random secret (256 bits of entropy). Openship stores **only a SHA-256 hash** of it — the plaintext is shown to you exactly once, at creation, and can never be retrieved again. A token always **acts as the person who created it** and carries that person's role, unless you narrow it (see below). ### Create one In the dashboard, open **Settings → Tokens** and use the **Personal Access Tokens** section. Copy the token when it appears — that's the only time you'll see it. Settings → Tokens tab, the "Personal Access Tokens" section with the create form and a freshly minted token shown once. *(screenshot pending)* From the terminal: ```bash # A full-access token that acts as you openship token create "my laptop" # A read-only token that expires in 90 days openship token create "ci-readonly" --read-only --expires 90 # A token scoped to one project (read + write only on that project) openship token create "deploy-bot" --grant project:proj_123:read,write openship token list # your tokens (never shows secrets) openship token revoke ``` `openship login` stores a token so the CLI can send it on every call. Run it with `--token opsh_pat_…` for a non-interactive login (handy in CI), or run it bare to open the token settings page and paste one in. The stored token is only ever sent as a `Bearer` header — never as a cookie. The request body behind the create call: ### Two ways to narrow a token - **Read-only** — the token can call read endpoints but any mutating request is refused with `403 TOKEN_READ_ONLY`. Great for dashboards, monitoring, and CI that only needs to look. - **Scoped** — instead of acting with your full role, the token is confined to a specific list of resources you grant it (a project, a server, a repository, …), each with `read`, `write`, or `admin`. A scoped token acts as a **restricted principal**: it can do *less* than you, never more. You can only grant access you already hold yourself — the server checks this before minting. Each token is bound to the organization it was created in. If a request carries an `X-Organization-Id` header naming a different org, it's rejected with `403 TOKEN_ORG_SCOPE`. An invalid or revoked token returns `401 INVALID_TOKEN`. ## MCP OAuth (AI agents) Openship is a standards-compliant **OAuth 2.1** server for the **Model Context Protocol (MCP)** — the way AI agents call tools. An MCP-capable client (Claude, Cursor, …) points at `POST /api/mcp`, and the handshake is automatic: the first unauthenticated request returns a `401` pointing at `/.well-known/oauth-protected-resource`, the client registers itself, runs the **PKCE** authorize flow (Proof Key for Code Exchange — a way to complete OAuth safely without a stored client secret), and you approve it on a **consent screen** in your browser. That consent screen is where you set the agent's limits — **read-only** and/or a specific set of projects, servers, and repositories. Your choices are saved as the client's binding and enforced through the exact same grant model as a scoped PAT. An OAuth token that somehow skips consent has **no binding, and is therefore denied everything** — there is no "authenticated but unscoped" state. You manage connected agents under **Settings → MCP**, where you can see each client and disconnect it (which revokes its tokens immediately). Clients without OAuth support can instead authenticate with a plain PAT as a bearer credential. Endpoint URLs, client config snippets, and verification steps live on the [MCP page](/docs/mcp). ## Desktop / zero-auth loopback mode The desktop app runs an API and a dashboard on your own machine, just for you. Forcing a login there would be pointless friction, so it uses **zero-auth mode**: the API auto-provisions a single local admin user (`Local User`, owner of a personal workspace) and treats local traffic as that user. No password, no cookie to manage. This is safe only because it is gated twice, and both gates must pass: 1. **Not CLI-managed or public.** Zero-auth is for the **desktop app only**. An instance started from the CLI (`openship up` sets `OPENSHIP_REQUIRE_AUTH`) or served publicly (`--public-url` / `OPENSHIP_PUBLIC_URL`) refuses zero-auth outright — it always requires a login. 2. **Opt-in.** The instance's auth mode must be `none`. This is the default for the desktop app; on any other deployment it also requires the operator to set `OPENSHIP_ALLOW_ZERO_AUTH=true`. 3. **Loopback only.** The request must come from a loopback address (`127.0.0.1` / `::1`), read from the kernel-reported TCP peer — **not** the `Host` header, which a client or a misconfigured reverse proxy could forge. A request from anywhere else is rejected with `401`, even in zero-auth mode. With `--public-url`, the dashboard's same-origin proxy runs **on the box**, so it reaches the API from loopback — which would otherwise slip past the loopback gate (condition 3). The explicit CLI/public refusal closes that hole, so a remote visitor can never land on the zero-auth path. On a normal self-hosted install this path is never even reached: the auth mode defaults to `local`, so a missing session is a plain `401`. Turning it on is a deliberate operator choice — auth mode `none` **and** `OPENSHIP_ALLOW_ZERO_AUTH=true` — and even then the loopback check keeps it usable only from the machine itself. A request arriving through a network reverse proxy carries the proxy's address as its TCP peer, not loopback, so it is refused. Openship recognizes three auth modes overall: - **`none`** — zero-auth loopback, described above. Default for the desktop app only; never used by a CLI-started or public instance. - **`local`** — standard login required (email + password via [Better Auth](https://www.better-auth.com/)). Default for a fresh self-hosted install, and always used for CLI (`openship up`) and public instances. - **`cloud`** — the desktop app signs in with an Openship Cloud account instead of a local one. A CLI or self-hosted instance has no public sign-up. The first admin (email + password) is created during the `openship` guided setup, over an internal-token-gated, one-shot endpoint that a browser can't reach. After that, add more users from **Settings → Team** in the dashboard. If the session machinery itself errors (a database outage, a decryption failure), the request returns `503 AUTH_UNAVAILABLE`. It does **not** quietly drop to the zero-auth path — a broken session check can never be mistaken for "no login required." ## Cloud sign-in Choosing **Continue with Cloud** (or connecting a self-hosted instance to Openship Cloud) runs a PKCE handshake that mints a **cloud session** for the connecting owner. That session is stored **encrypted, server-side only** — the browser never sees it — and org-scoped cloud calls act on the owner's behalf through it. How that boundary is kept clean is covered in [The local ↔ cloud boundary](/docs/security/cloud-boundary). ## Where credentials live Openship keeps secrets where they're safest instead of copying them around: - **PAT secrets** are stored only as a SHA-256 hash; the plaintext exists just once, in your clipboard. - **Cloud sessions** are encrypted at rest on the instance and never exposed to the browser. - The **GitHub App private key** lives only on Openship Cloud; self-hosted instances mint short-lived App tokens through the cloud rather than holding the key. - Per-project git tokens and environment secrets are encrypted at rest. ## Revocation - **A token:** `openship token revoke `, or the **Revoke** button in Settings → Tokens. It stops working on its next request. - **An MCP client:** disconnect it under Settings → MCP — its issued tokens are revoked and the consent is dropped, so reconnecting re-prompts. - **A person:** removing a member ends their access; their session stops working on its next check. - **A cloud link:** disconnecting an instance revokes its cloud session. ## Errors you might see | Status & code | Meaning | |---|---| | `401 INVALID_TOKEN` | The bearer token is wrong, expired, or revoked. | | `401 BEARER_NOT_ALLOWED_FROM_BROWSER` | A bearer token arrived from a dashboard browser origin — send it from a CLI/server instead. | | `403 TOKEN_READ_ONLY` | A read-only token tried a mutating request. | | `403 TOKEN_ORG_SCOPE` | The token is bound to a different organization than the one requested. | | `401 Unauthorized` | No valid session, or a zero-auth request that failed the mode/loopback gates. | | `503 AUTH_UNAVAILABLE` | The session check itself failed — retry; it never silently downgrades to no-auth. | ## What next?