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 thecrewship 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/) readsRESEND_API_KEYandRESEND_FROMat startup; without them,/forgotstill returns 200 but nothing is sent and users must recover via the Admin CLI.CREWSHIP_PUBLIC_URLmust also be set to a validhttp(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-tokenwhile 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 viaDELETE /api/v1/auth/cli-tokens/{tokenId}. - You’re pairing the
crewshipCLI 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-innext-authclient 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.goerror logs for token-redemption failures.
/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./forgot still returns 200 to preserve non-enumeration).
2. Configure the mailer (optional)
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.
4. Bootstrap the first admin
On a fresh database, the/bootstrap page or endpoint creates the initial OWNER:
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 viaPOST /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.- UI
- CLI
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.
/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.
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.
7. Mint a CLI token (for scripts / CI)
While session-authed in the web UI: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)
Passscopes when minting to restrict what the token can do — a CI token that only manages agents never needs to delete crews or rotate credentials:
<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 anyagentsroute;*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
:readscopes yet. Reads are not scope-gated in this pass (GET routes register outside the mutation chokepoint), and there is no route-mappedagents: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.
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:
--email to skip the email prompt too:
--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.
Recovery flow walkthrough
If a user forgets their password and the mailer is configured: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.CI bootstrap with no mailer configured
A Docker-based deployment pipeline must produce a logged-in-able admin account, butRESEND_API_KEY isn’t available in the build environment.
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 installedcrewship on a fresh laptop and wants to authenticate it against their team’s server.
API reference
Every endpoint below lives ininternal/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)
ThePOST /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 forPOST 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 settingGOOGLE_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_URLis 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 tor.Host; theHostheader is attacker-tainted, and the resulting reset link would point at whatever origin the attacker put in theirPOST /api/v1/auth/forgotrequest. Set the env var to your real public origin or accept that recovery requires the Admin CLI.- Never leak whether an email is registered.
/forgotalways returns 200 and/signupalways returns 202 — preserve both. Adding “user not found”, “we just sent you an email”, or (the pre-2026-07 signup behaviour) a409 Email already registeredgives 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
/forgotuses. The notice is dispatched after the202is 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-tokenresponse 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/resetinvalidates 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_SECRETon an upgraded box cannot quietly switch it back on. - Signup is OFF by default for self-hosted installs. The
CREWSHIP_ALLOW_SIGNUPserver flag gates it. On a multi-tenant SaaS deployment you’d set it totrue; for a self-hosted single-org install, leaving/api/v1/auth/signupreturning 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/credentialswithout first GETting/api/auth/csrfand 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 intentionallyHttpOnly+Secure+SameSite=Lax. Anything that lifts it into JS-readable storage breaks the XSS-resistance promise of server-side sessions.
Related
crewship authCLI reference — includes the profile picture (crewship auth avatar ./me.png/--clear; PNG/JPEG/WebP ≤ 2 MB, backed byPOST/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_URLandRESEND_API_KEYenv vars that the recovery flow depends on.