Authentication
How you prove who you are to Openship — session cookies, personal access tokens, MCP OAuth, and desktop loopback mode.
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.
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.
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.
- 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.
- Scan the QR code with an authenticator app, or enter the setup key manually.
- Enter the app's six-digit code. Protection starts only after this verification succeeds.
- 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.
Bearer tokens are refused from the dashboard's own origin
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.
From the terminal:
# 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>Use a token from the CLI
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:
Prop
Type
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, oradmin. 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.
A token is tied to one organization
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.
Full MCP setup
Endpoint URLs, client config snippets, and verification steps live on the MCP page.
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:
Every condition is required
- Not CLI-managed or public. Zero-auth is for the desktop app only. An instance started from the
CLI (
openship upsetsOPENSHIP_REQUIRE_AUTH) or served publicly (--public-url/OPENSHIP_PUBLIC_URL) refuses zero-auth outright — it always requires a login. - 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 setOPENSHIP_ALLOW_ZERO_AUTH=true. - Loopback only. The request must come from a loopback address (
127.0.0.1/::1), read from the kernel-reported TCP peer — not theHostheader, which a client or a misconfigured reverse proxy could forge. A request from anywhere else is rejected with401, even in zero-auth mode.
Why condition 1 exists
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, configured OAuth, or a registered passkey via Better Auth). 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.
Creating the first admin
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.
One failure that never falls through
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.
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
Cloud boundary
How a self-hosted instance talks to Openship Cloud without leaking local identity — proxied requests carry the owner's cloud session and nothing else.
Troubleshooting
Where Openship shows you what went wrong — the build screen, the Logs tab, and the CLI — and how to read an error before you try to fix it.