# Authentication
URL: https://openship.io/docs/security/auth.md

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.

<Callout title="Base path">
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).
</Callout>

## 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.

### Passkeys

On Openship Cloud or a self-hosted instance with local accounts, open **Settings → Security → Passkeys**
after signing in. Add a passkey using Face ID, Touch ID, Windows Hello, a password manager, or a
compatible hardware security key. Registration requires a recent session and a discoverable credential
so the login page can find it without a username. The private key stays with your authenticator;
Openship stores the public WebAuthn credential.

Choose **Sign in with a passkey** on the login page. If you enabled two-factor authentication, complete
that step before a session is issued. You can remove saved passkeys from Settings even in a browser
that cannot create them. Keep another sign-in method available before removing your last passkey.

Passkeys require HTTPS, except on localhost. They bind to the configured **dashboard** hostname and
browser origin, independently of the API host. Self-hosted public instances use `OPENSHIP_PUBLIC_URL`;
Cloud uses its dashboard runtime URL. Keep the hostname stable: moving to another domain requires
registering a passkey there. Use your password or social login to do that.

### Two-factor authentication

Open **Settings → Security → Two-factor authentication** on Openship Cloud or a self-hosted instance
using `OPENSHIP_AUTH_MODE=local` (including a VPS). No email delivery or additional provider is needed
for authenticator codes.

1. Choose **Set up 2FA** and confirm your current password. If you have only used social login, first
   choose **Add password**; this requires a recent signed-in session and does not replace social login.
2. Scan the QR code with an authenticator app, or enter the setup key manually.
3. Enter the app's six-digit code. Protection starts only after this verification succeeds.
4. Copy or download your recovery codes and store them somewhere safe. Each code works once.

When 2FA is enabled, password, social, and passkey sign-in all require an authenticator or recovery
code before creating a usable session. Pending challenges expire after ten minutes. Browser handoffs,
invitations, and MCP authorization continue after verification. Password resets do not disable 2FA.

Use **Recovery codes** to replace your codes, or **Turn off 2FA** to disable protection; both require
your password. Replacing codes invalidates the old set. An enabled authenticator cannot be overwritten
by starting setup again: disable it explicitly first. Keep your authenticator or a recovery code
available; an email password reset is not a way to bypass the second factor.

These controls do not apply to account-free local mode. Instances that delegate sign-in to Openship
Cloud manage account security on Cloud rather than storing a separate local factor.

Authentication tables are added automatically during the normal database upgrade. Back up and retain
your installation's `BETTER_AUTH_SECRET`, which encrypts authenticator secrets and recovery codes.
An instance transfer must include credentials to preserve accounts with 2FA enabled; the destination
re-encrypts them with its own key. An incomplete factor restore is rejected before changing data.
Project-only transfers do not move account credentials.

<Callout title="Bearer tokens are refused from the dashboard's own origin" type="warn">
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.
</Callout>

## 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.

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 <id>
```

<Callout title="Use a token from the CLI" type="info">
`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.
</Callout>

The request body behind the create call:

<TypeTable
  type={{
    name: { type: 'string', description: 'Human-readable label for the token.', required: true },
    readOnly: { type: 'boolean', description: 'Reject every mutating method (POST/PUT/PATCH/DELETE).', default: 'false' },
    expiresInDays: { type: 'integer', description: 'Expire after N days (1–365). Omit for a non-expiring token.' },
    grants: { type: 'Grant[]', description: 'Scope the token to specific resources. When present, the token becomes a restricted principal limited to exactly these grants.' },
  }}
/>

### 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.

<Callout title="A token is tied to one organization" type="info">
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`.
</Callout>

## 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.

<Callout title="Full MCP setup">
Endpoint URLs, client config snippets, and verification steps live on the [MCP page](/docs/mcp).
</Callout>

## 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:

<Callout title="Every condition is required" type="warn">
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.
</Callout>

<Callout title="Why condition 1 exists" type="info">
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.
</Callout>

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, configured OAuth, or a registered passkey 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.

<Callout title="Creating the first admin" type="info">
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.
</Callout>

<Callout title="One failure that never falls through" type="error">
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."
</Callout>

## 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 managed **Openship GitHub App private key** lives only on Openship Cloud. A self-hosted workspace may
  instead register one or more Apps in Settings; each private key, client secret, and webhook secret is
  stored in a versioned AES-256-GCM envelope and is never returned by the API. The legacy process-environment
  configuration remains supported. Every App path mints short-lived, repository-scoped installation tokens.
- Per-project git tokens and environment secrets are encrypted at rest.

## Revocation

- **A token:** `openship token revoke <id>`, 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. |

## Related guides

<Cards>
  <Card title="Permission model" href="/docs/security/permissions" description="What a logged-in identity is actually allowed to do — roles and resource grants." />
  <Card title="Connect an AI agent (MCP)" href="/docs/mcp" description="Endpoint URLs, client config, and verification for MCP clients." />
  <Card title="The local ↔ cloud boundary" href="/docs/security/cloud-boundary" description="How self-hosted instances talk to Openship Cloud without leaking identity." />
</Cards>
