Skip to main content

Authentication

Overview

Authentication in Crewship is the gate every other surface sits behind — agents, credentials, journal, runs, even the onboarding wizard run inside a session, and every subsystem trusts the user identity stamped on the request without re-checking. The auth layer therefore has to handle four distinct entry points: an email/password sign-in for the web UI, an out-of-band password recovery path for accounts that have lost their credential, and a token-and-pairing handshake for the crewship CLI to authenticate against the same server. The implementation funnels every entry point through one router file (internal/api/router_auth.go) so the entire surface can be audited at once. Routes split into four trust tiers: /api/v1/auth/signup, which is public and establishes no session — it answers 202 and the client signs in afterwards (see the non-enumeration note below); public bootstrap endpoints (/bootstrap, OAuth callbacks, password-reset redemption) that do establish a session cookie; session-authed endpoints (session list/revoke, CLI token issuance, pairing start/poll) that act on the caller’s own identity; and the NextAuth-compatible /api/auth/* shim that lets the Next.js frontend treat Crewship as a drop-in NextAuth provider. Passwords are persisted as bcrypt hashes; CLI tokens as SHA-256 digests (STANDARD tier) or HMAC-SHA256 digests keyed by a server-side secret (ADMIN tier) — none is recoverable from the database. Two safety properties are load-bearing and worth knowing before changing this code. First, /forgot always returns 200 (and /signup always 202) regardless of whether the email matches a real account, so neither endpoint can be used to enumerate users; real-vs-fake is signalled only by what arrives in the user’s inbox. Second, the reset-link origin is CREWSHIP_PUBLIC_URL and never r.Host — the latter is attacker-tainted via the Host header and would otherwise let a POST /forgot mail the victim a working reset link pointing at an attacker-controlled domain. The handler refuses to send reset mail if CREWSHIP_PUBLIC_URL is unset or malformed, on the principle that a broken email is strictly safer than a hijackable one.

When to use it

Reach for this guide when you’re configuring, debugging, or extending an authentication path — not for ordinary login flows, which Just Work via the web UI:
  • You’re enabling in-band password recovery. The mailer (internal/mailer/) reads RESEND_API_KEY and RESEND_FROM at startup; without them, /forgot still returns 200 but nothing is sent and users must recover via the Admin CLI. CREWSHIP_PUBLIC_URL must also be set to a valid http(s) origin or the handler refuses to mail reset links.
  • You’re issuing long-lived CLI tokens for scripts, CI, or another machine without an interactive browser. Tokens are minted via POST /api/v1/auth/cli-token while session-authed, persisted as SHA-256 hashes (safe because the cleartext is 256-bit random — no dictionary to attack; ADMIN-tier tokens upgrade to keyed HMAC-SHA256), and revocable from Settings → Sessions or via DELETE /api/v1/auth/cli-tokens/{tokenId}.
  • You’re pairing the crewship CLI on a new dev machine. The device-code handoff produces the same token type as the in-app issuer but with the convenience that the user never has to copy-paste a secret — see the dedicated CLI Pairing guide for the device-code flow itself.
  • You’re plugging the Next.js frontend (or another NextAuth client) into Crewship. The /api/auth/* shim implements the NextAuth contract — CSRF, providers, session, callback/credentials, token refresh, signin/signout, error — so a drop-in next-auth client can treat Crewship as a regular provider without bespoke glue.
  • You’re investigating a “can’t log in” report. Order of checks: crewship admin list-users (the row exists with the email casing the user typed?), session cookie present in the browser request, mailer logs (was a reset link sent?), auth_recovery.go error logs for token-redemption failures.
For one-off recovery without shell access, the in-band /forgot flow above is the answer; for an admin locked out at the host level, jump to the Admin CLI guide instead.

Key concepts

Usage

The end-to-end operator path from a fresh binary to working login + recovery is six configuration steps and one bootstrap call. None of them are mandatory after the first — once the env vars are set and the admin exists, day-to-day sign-in needs nothing else.

1. Set the public origin

Required if you intend to enable in-band password recovery. This is the URL the reset email links to.
The recovery handler parses this once at startup; a malformed or unset value silently disables reset-email sending (/forgot still returns 200 to preserve non-enumeration).

2. Configure the mailer (optional)

Without these, the mailer falls back to mailer.Disabled — installs without email still work, but users can’t recover passwords in-band. They must instead use Admin CLI reset-password from the host shell.

3. Google sign-in (removed)

Google OAuth is switched off. /api/v1/auth/google/redirect and /callback are no longer registered, so setting GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET does nothing. /api/v1/auth/google/status still answers {"enabled": false} so an older frontend build gets a definite answer rather than an error.
Accounts created by the old flow have no password — Google was their only credential. Their route back in is Forgot password, which works because the address is on file.Re-adding them through Settings → Members does not help: the linked provider row makes the account count as claimed, so no setup link is issued. That is deliberate. Such an account is genuinely in use by somebody, and minting a password-setting link for it would be a takeover, not a recovery.

4. Bootstrap the first admin

On a fresh database, the /bootstrap page or endpoint creates the initial OWNER:
The endpoint is idempotent — a second call against a database that already has any user returns 403 (Already initialized — bootstrap is only available on an empty database), so re-running provisioning is safe.

5. Sign in (day-to-day)

The Next.js frontend handles credentials via POST /api/auth/callback/credentials, sets a session cookie, and every subsequent request rides on RequireAuth. No client-side state, no JWT inspection — the session is server-owned.

6. Add a colleague

Adding someone needs no mail server. The workspace admin creates the account and hands over a one-time setup link; the new person sets their own password with it.
Settings → Members → Add member. Enter their email and role. Crewship creates the account if the email is new, adds it to the workspace, and shows a setup link. Copy it and send it however you like — Slack, SMS, in person.The link is shown once. The token is stored hashed, so it cannot be displayed again; if it goes astray before it is used, add the person again to issue a fresh one (which invalidates the old). Once they have set a password the account is claimed, and re-adding stops issuing links — see below.
The link lands on /reset-password, the same page a forgotten-password flow uses, and expires after 7 days — long enough to send on a Friday and be opened on Monday. (An ordinary password-reset link is 30 minutes, because the person requesting it is waiting at the screen.) If the email already belongs to a claimed account, that person is added to the workspace and no setup link is issued — they sign in with the credential they already have. Issuing one would hand whoever holds the link the ability to change that credential, and creating a workspace is self-serve. An account counts as claimed if any one of these is true: An account with none of them is unclaimed — that is exactly what this endpoint creates. Adding an unclaimed person issues a fresh link and invalidates the previous one, which is how you recover a link that went astray before it was used.
Recovering a link takes two steps, because membership is checked before any of this: adding someone who is already a member returns 409 and issues nothing, whatever their account looks like.So remove them from the workspace first, then add them again:
They keep the same user account throughout — only the membership row is recreated.
Requires ADMIN or OWNER. The link’s origin is derived from the request, so a default install needs no configuration. Set CREWSHIP_PUBLIC_URL only when Crewship sits behind a proxy that rewrites the host — then it pins the externally reachable URL instead.
This is the one place the origin is not taken from CREWSHIP_PUBLIC_URL first, which is worth explaining because the rule stated in the overview is otherwise absolute.A /forgot reset link is built for an unauthenticated request and then mailed to somebody else, so trusting the Host header would let an attacker POST /forgot and have Crewship mail the victim a working link pointing at an attacker-controlled domain. Setup links have neither property: the request is ADMIN-authenticated, and the link is returned in the response to that same admin’s own browser. A forged Host there only corrupts the link the forger receives — there is nobody else to mislead and nothing to escalate. Requiring an env var for it would mean a self-hosted install could not add a user without editing the environment first, which is the whole point of this flow.
Once a mailer is configured, the same action can also email the link. The copyable link stays as the fallback that works on an air-gapped or mail-less instance.
crewship workspace invite is a different, older command: it records an invitation row but sends nothing, because no mailer is wired to it. Use workspace member invite above to actually get someone in.

7. Mint a CLI token (for scripts / CI)

While session-authed in the web UI:
Store token in CI secrets and pass it as Authorization: Bearer <token> on subsequent requests. Revoke with DELETE /api/v1/auth/cli-tokens/{tokenId} from the same UI or via the API.

Scoping a token (least privilege)

Pass scopes when minting to restrict what the token can do — a CI token that only manages agents never needs to delete crews or rotate credentials:
Scopes use the shape <resource>:<action>. The recognised vocabulary is deliberately limited to scopes that actually gate a route today — every scope you can mint maps to at least one endpoint (enforced by a test), so a “restricted” token is never a token that silently grants nothing: How scopes are enforced. Every state-changing route (POST/PUT/PATCH/DELETE) declares the scope it requires; the route-table chokepoint checks it right after the role check, so enforcement can’t be forgotten per-handler. A restricted token missing the route’s scope is refused with 403 before the handler runs. Semantics:
  • Wildcards subsume: agents:* satisfies any agents route; * satisfies everything.
  • Least privilege is coarse today: the broad workspace-management surface (projects, issues, pipelines, integrations, admin, feature flags, …) currently maps to workspace:admin. Finer scopes for that surface are planned.
  • No :read scopes yet. Reads are not scope-gated in this pass (GET routes register outside the mutation chokepoint), and there is no route-mapped agents:run — so those scopes aren’t offered rather than shipped as no-ops. They return when a route requires them (read-scoping / invocation-scoping are tracked follow-ups). A scoped token can still read.
  • Self-scoped mutations are scope-exempt. A handful of mutations are gated by ownership instead of a scope — for example the one-shot programmatic chats an agent creates for itself: create, read, and delete are all authorized by the creator-or-agent-editor check, so a narrowly-scoped token (e.g. agents:write) can clean up its own chats without needing a broader scope.
Backward compatibility. A token minted without scopes (the default, and every token issued before enforcement) carries the full role of the issuing user — unchanged behavior. Scopes only ever narrow a token; you can never mint one above your own role.

8. Non-interactive CLI login (systemd / CI / harnesses)

crewship login’s default email+password flow prompts for the password on a real TTY (term.ReadPassword), which fails outright over a pipe:
That blocks any unattended process — a systemd timer re-authenticating after a dev-slot reseed, a CI job, a headless test harness — from obtaining a fresh CLI token on its own. Two non-interactive password sources exist for exactly this case, in addition to --email to skip the email prompt too:
Prefer --password-stdin when both work (preferred for CI / scripts — avoids argv leak): an environment variable is readable from /proc/<pid>/environ by anything running as the same user and is inherited by every child process, so on shared machines CREWSHIP_PASSWORD exposes the password more broadly than a stdin pipe does. Reserve the env var for runners that can only inject secrets through the environment. --password-stdin and CREWSHIP_PASSWORD are mutually exclusive — passing both is refused rather than silently picking one, since it usually means the caller meant one of them and mistyped. Both paths go through the same CSRF+credentials exchange and CLI-token mint as interactive login, so the persisted token behaves identically (profile-aware, revocable from Settings → CLI tokens). For a token-only automation (no interactive login ever happened, or the password shouldn’t be redistributed at all), mint a scoped CLI token once from a session-authed browser (§6) and use crewship login --token <token> instead — that path already avoided the TTY entirely.

The admin authorization floor

The admin console and everything behind it sit on one uniform floor: ADMIN+ (ADMIN or OWNER). This is enforced at the route table, not per-handler, so the read surface and the destructive mutations can’t drift apart.
  • /admin/* and the admin console — every admin route requires ADMIN+. Reads (stats, users, workspaces, memory versions, backups listing, keeper audit) go through the same floor as the mutations they sit next to, so an ADMIN who can drive the destructive APIs can also open the console that fronts them. A below-floor MEMBER/VIEWER gets 403.
  • GET /api/v1/system/keeper — ADMIN+, and its request counts are scoped to your workspace (previously it returned instance-wide totals to any authenticated user). It also reports the configured Ollama URL/model, which is why it carries the admin floor.
  • GET /api/v1/system/runtime — reachable by any authenticated user (the runtime banner and onboarding probe it), but host detail is redacted unless you resolve to ADMIN+ in a workspace: non-admins get { "available": true|false }, admins additionally get container versions and socket paths. Pass ?workspace_id=<id> so the server can resolve your role.
  • GDPR endpoints keep their documented separation (see the Admin CLI and data-handling guides) — the erase/export routes are ADMIN+ like the rest of the surface.
Roles rank VIEWER < MEMBER < MANAGER < ADMIN < OWNER; “ADMIN+” means ADMIN or OWNER. The floor is pinned by a route-table invariant test, so a new admin route that forgets the gate fails the build.

Recovery flow walkthrough

If a user forgets their password and the mailer is configured:
If the mailer is mailer.Disabled, step 2 never happens — the user must go through host-level Admin CLI recovery instead.

Examples

Onboarding a team without SSO

The product manager wants the team on Crewship. Google sign-in is switched off, so accounts are created directly and each person sets their own password.
Each prints a one-time setup link on its own line. Send them however you like — the link is valid for 7 days and the person chooses their own password with it. Nothing is emailed, so no mailer is required.

CI bootstrap with no mailer configured

A Docker-based deployment pipeline must produce a logged-in-able admin account, but RESEND_API_KEY isn’t available in the build environment.
This is the canonical “self-hosted without email” path — the mailer.Disabled fallback keeps /forgot non-enumerable, and the admin CLI gives the operator an out-of-band recovery channel that doesn’t depend on Resend.

Pairing the CLI from a new machine

A developer just installed crewship on a fresh laptop and wants to authenticate it against their team’s server.
The whole exchange takes under a minute and the developer never has to copy or type a long credential — see CLI Pairing for the device-code flow details and security properties.

API reference

Every endpoint below lives in internal/api/router_auth.go. There’s no dedicated /api-reference/auth page — the surface is small enough to inline, and the file is the source of truth.

Bootstrap & signup (public)

Sign-in & sessions (mixed)

The POST /api/auth/* rows use the browser’s CSRF/session cookies and return NextAuth JSON or redirects; the /api/v1/auth/sessions rows require the caller’s session and return JSON. Statuses: 200 success; 401 invalid credentials or session; 403 CSRF/origin failure; 405 wrong method; 500 server failure.

Password recovery (public)

Request: JSON body with the email or reset token/password. Response: generic recovery JSON to prevent account enumeration. Statuses: 200 for forgot; 400 invalid input/token; 500 server failure.

CLI tokens & device pairing (mixed)

Request: JSON for POST rows and the pairing-code query for poll; response: token, metadata, or pairing status JSON. Statuses: 200 success; 400 invalid code/body; 401 when auth is required; 404 unknown owned token/session; 500 server failure.

Google OAuth (removed)

The redirect and callback routes are no longer registered, so setting GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET has no effect. Response: {"enabled":false}. Statuses: 200 for the compatibility probe; 404 for the removed redirect/callback routes.

Onboarding (auth-required, but workspace-less)

Mounted alongside auth because the wizard runs before any workspace context exists. Request: JSON for setup/complete and no body for status. Response: wizard state JSON. Statuses: 200 success; 400 invalid step; 401 unauthenticated; 500 server failure.

Common pitfalls

  • CREWSHIP_PUBLIC_URL is load-bearing for security, not just UX. If you leave it unset and configure a mailer, the handler refuses to send reset links — by design. Don’t be tempted to “fix” it by falling back to r.Host; the Host header is attacker-tainted, and the resulting reset link would point at whatever origin the attacker put in their POST /api/v1/auth/forgot request. Set the env var to your real public origin or accept that recovery requires the Admin CLI.
  • Never leak whether an email is registered. /forgot always returns 200 and /signup always returns 202 — preserve both. Adding “user not found”, “we just sent you an email”, or (the pre-2026-07 signup behaviour) a 409 Email already registered gives an attacker an account-enumeration oracle. The same rule covers response shape: signup stopped returning the new account’s id and stopped setting a session cookie because both appear only on the created path. The browser-side UX intentionally says “If an account exists…” for the same reason.
  • Signup’s collision is handled by email, not by the response. When the address is taken, the owner gets a “someone tried to sign up with your email” notice (only if a mailer is configured; otherwise it is logged with the address hashed) — exactly the split /forgot uses. The notice is dispatched after the 202 is written, from a detached goroutine — held on the request path it would make the taken-address response measurably slower than the created one on every instance with a mailer, which is the same oracle in the time domain. Both paths also burn one bcrypt at cost 12, so the dominant cost is the same on each. This equalises the response body, status, cookies, and the two large costs on the request path — it is not a constant-time guarantee: the created path still writes a transaction, so a determined attacker with a clean network path and enough samples may still see a difference. Closing that needs the verification-before-activation work tracked separately.
  • CLI tokens are not retrievable after creation. Only the SHA-256 (or, for ADMIN tier, HMAC-SHA256) hash is stored; the raw token is shown exactly once at POST /api/v1/auth/cli-token response time. If a user loses theirs, the path is “revoke + mint new” — don’t add an endpoint that re-displays the value.
  • Pairing codes are single-use and 10-minute-TTL’d. If a user starts pairing and walks away, the code expires on its own. Don’t extend the TTL to “be nice” — the security argument depends on a short window where a phished code can’t be used.
  • /api/v1/auth/reset invalidates ALL of the user’s sessions. This is a feature: if a password reset happens because of suspected compromise, leaving old session cookies valid would defeat the rotation. Anything UI-side that depends on “logged in everywhere” must reauthenticate after a reset.
  • Google sign-in cannot be re-enabled by configuration. The routes are unregistered in code, not gated on env vars, so leftover GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET on an upgraded box cannot quietly switch it back on.
  • Signup is OFF by default for self-hosted installs. The CREWSHIP_ALLOW_SIGNUP server flag gates it. On a multi-tenant SaaS deployment you’d set it to true; for a self-hosted single-org install, leaving /api/v1/auth/signup returning 403 (Registration is disabled. Set CREWSHIP_ALLOW_SIGNUP=true to enable.) is correct.
  • NextAuth clients require the CSRF dance. A frontend that POSTs /api/auth/callback/credentials without first GETting /api/auth/csrf and sending the token gets 401. This is the NextAuth contract — not optional even when you “trust” the origin.
  • Bcrypt cost factor must match across signup, admin CLI, and pairing redemption. All three paths produce hashes the same verifier reads. If you change the cost factor in one place and not the others, you’ve just made some users’ passwords slower to verify than others — measurable timing oracle.
  • Don’t store the session cookie in localStorage. It’s intentionally HttpOnly + Secure + SameSite=Lax. Anything that lifts it into JS-readable storage breaks the XSS-resistance promise of server-side sessions.
  • crewship auth CLI reference — includes the profile picture (crewship auth avatar ./me.png / --clear; PNG/JPEG/WebP ≤ 2 MB, backed by POST/DELETE /api/v1/users/me/avatar) alongside login and password commands.
  • Onboarding — the first-run flow that bootstraps the initial admin account.
  • Credentials — where the auth layer’s SECRET-tier credentials (OAuth client secrets, mailer keys) live.
  • Installation — the CREWSHIP_PUBLIC_URL and RESEND_API_KEY env vars that the recovery flow depends on.